merge: integrate main into feat/v3-pipeline + port AsyncMemory to v3

Merge 190 commits from main including:
- limit→top_k rename across all vector store APIs
- custom_fact_extraction_prompt→custom_instructions rename
- enable_graph flag removal (use self.graph truthiness)
- US/Pacific→timezone.utc normalization
- 18 Category A bug fixes (partial updates, graph cleanup on delete,
  telemetry gating, metadata mutation prevention, filter operator
  merging, delete timestamps, actor_id preservation, etc.)

Conflict resolutions:
- openai.py: merged _pass_dimensions_to_api with embed_batch
- milvus.py: merged partial-update logic with BM25 text field
- qdrant.py: took main's enhanced _create_filter, merged BM25
  sparse vectors with partial-update endpoints
- test_main.py: adopted main's test structure, removed obsolete
  test_custom_prompts, kept hybrid search over-fetch assertion

AsyncMemory v3 port (full parity):
- Rewrote AsyncMemory._add_to_vector_store with the same 8-phase
  batch pipeline as sync Memory (single LLM call, batch embed,
  hash dedup, batch persist, batch entity linking, message saving)
- All blocking calls wrapped in await asyncio.to_thread()
- Moved _build_session_scope to module level for shared access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Soumil Rathi
2026-04-12 16:26:10 -07:00
co-authored by Claude Opus 4.6
655 changed files with 75693 additions and 8779 deletions
+22 -3
View File
@@ -19,13 +19,13 @@ def test_embed_text(mock_ollama_client):
config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512)
embedder = OllamaEmbedding(config)
mock_response = {"embedding": [0.1, 0.2, 0.3, 0.4, 0.5]}
mock_ollama_client.embeddings.return_value = mock_response
mock_response = {"embeddings": [[0.1, 0.2, 0.3, 0.4, 0.5]]}
mock_ollama_client.embed.return_value = mock_response
text = "Sample text to embed."
embedding = embedder.embed(text)
mock_ollama_client.embeddings.assert_called_once_with(model="nomic-embed-text", prompt=text)
mock_ollama_client.embed.assert_called_once_with(model="nomic-embed-text", input=text)
assert embedding == [0.1, 0.2, 0.3, 0.4, 0.5]
@@ -41,3 +41,22 @@ def test_ensure_model_exists(mock_ollama_client):
embedder._ensure_model_exists()
mock_ollama_client.pull.assert_called_once_with("nomic-embed-text")
def test_ensure_model_exists_normalizes_latest_tag(mock_ollama_client):
"""Model 'nomic-embed-text' should match 'nomic-embed-text:latest' from ollama list."""
mock_ollama_client.list.return_value = {"models": [{"name": "nomic-embed-text:latest"}]}
config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512)
OllamaEmbedding(config)
mock_ollama_client.pull.assert_not_called()
def test_embed_empty_response_raises(mock_ollama_client):
config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512)
embedder = OllamaEmbedding(config)
mock_ollama_client.embed.return_value = {"embeddings": []}
with pytest.raises(ValueError, match="returned no embeddings"):
embedder.embed("some text")
+39 -5
View File
@@ -24,7 +24,7 @@ def test_embed_default_model(mock_openai_client):
result = embedder.embed("Hello world")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Hello world"], model="text-embedding-3-small", dimensions=1536
input=["Hello world"], model="text-embedding-3-small", encoding_format="float"
)
assert result == [0.1, 0.2, 0.3]
@@ -39,7 +39,7 @@ def test_embed_custom_model(mock_openai_client):
result = embedder.embed("Test embedding")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Test embedding"], model="text-embedding-2-medium", dimensions=1024
input=["Test embedding"], model="text-embedding-2-medium", dimensions=1024, encoding_format="float"
)
assert result == [0.4, 0.5, 0.6]
@@ -54,7 +54,7 @@ def test_embed_removes_newlines(mock_openai_client):
result = embedder.embed("Hello\nworld")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Hello world"], model="text-embedding-3-small", dimensions=1536
input=["Hello world"], model="text-embedding-3-small", encoding_format="float"
)
assert result == [0.7, 0.8, 0.9]
@@ -69,7 +69,7 @@ def test_embed_without_api_key_env_var(mock_openai_client):
result = embedder.embed("Testing API key")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Testing API key"], model="text-embedding-3-small", dimensions=1536
input=["Testing API key"], model="text-embedding-3-small", encoding_format="float"
)
assert result == [1.0, 1.1, 1.2]
@@ -85,6 +85,40 @@ def test_embed_uses_environment_api_key(mock_openai_client, monkeypatch):
result = embedder.embed("Environment key test")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Environment key test"], model="text-embedding-3-small", dimensions=1536
input=["Environment key test"], model="text-embedding-3-small", encoding_format="float"
)
assert result == [1.3, 1.4, 1.5]
def test_embed_passes_encoding_format_float(mock_openai_client):
"""Verify encoding_format='float' is always passed to prevent base64 issues with proxies.
The OpenAI SDK defaults to encoding_format='base64' when not specified,
which breaks OpenAI-compatible proxies (OpenRouter, LiteLLM, vLLM, etc.)
that don't support base64 decoding. See #4057.
"""
config = BaseEmbedderConfig()
embedder = OpenAIEmbedding(config)
mock_response = Mock()
mock_response.data = [Mock(embedding=[0.1, 0.2, 0.3])]
mock_openai_client.embeddings.create.return_value = mock_response
embedder.embed("Proxy compatibility test")
call_kwargs = mock_openai_client.embeddings.create.call_args
assert call_kwargs.kwargs.get("encoding_format") == "float" or call_kwargs[1].get("encoding_format") == "float"
def test_embed_passes_dimensions_only_when_explicit(mock_openai_client):
"""Matryoshka / truncated embeddings: dimensions sent only if user sets embedding_dims (#4153)."""
config = BaseEmbedderConfig(embedding_dims=256)
embedder = OpenAIEmbedding(config)
mock_response = Mock()
mock_response.data = [Mock(embedding=[0.1] * 256)]
mock_openai_client.embeddings.create.return_value = mock_response
embedder.embed("truncate me")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["truncate me"], model="text-embedding-3-small", dimensions=256, encoding_format="float"
)
+5 -5
View File
@@ -48,7 +48,7 @@ def mock_text_embedding_input():
@patch("mem0.embeddings.vertexai.TextEmbeddingModel")
def test_embed_default_model(mock_text_embedding_model, mock_os_environ, mock_config, mock_text_embedding_input):
mock_config.return_value.model = "text-embedding-004"
mock_config.return_value.model = "gemini-embedding-001"
mock_config.return_value.embedding_dims = 256
config = mock_config()
@@ -59,7 +59,7 @@ def test_embed_default_model(mock_text_embedding_model, mock_os_environ, mock_co
embedder.embed("Hello world")
mock_text_embedding_input.assert_called_once_with(text="Hello world", task_type="SEMANTIC_SIMILARITY")
mock_text_embedding_model.from_pretrained.assert_called_once_with("text-embedding-004")
mock_text_embedding_model.from_pretrained.assert_called_once_with("gemini-embedding-001")
mock_text_embedding_model.from_pretrained.return_value.get_embeddings.assert_called_once_with(
texts=[mock_text_embedding_input("Hello world")], output_dimensionality=256
@@ -92,7 +92,7 @@ def test_embed_custom_model(mock_text_embedding_model, mock_os_environ, mock_con
def test_embed_with_memory_action(
mock_text_embedding_model, mock_os_environ, mock_config, mock_embedding_types, mock_text_embedding_input
):
mock_config.return_value.model = "text-embedding-004"
mock_config.return_value.model = "gemini-embedding-001"
mock_config.return_value.embedding_dims = 256
for embedding_type in mock_embedding_types:
@@ -103,7 +103,7 @@ def test_embed_with_memory_action(
config = mock_config()
embedder = VertexAIEmbedding(config)
mock_text_embedding_model.from_pretrained.assert_called_with("text-embedding-004")
mock_text_embedding_model.from_pretrained.assert_called_with("gemini-embedding-001")
for memory_action in ["add", "update", "search"]:
embedder.embed("Hello world", memory_action=memory_action)
@@ -151,7 +151,7 @@ def test_embed_with_different_dimensions(mock_text_embedding_model, mock_os_envi
@patch("mem0.embeddings.vertexai.TextEmbeddingModel")
def test_invalid_memory_action(mock_text_embedding_model, mock_config):
mock_config.return_value.model = "text-embedding-004"
mock_config.return_value.model = "gemini-embedding-001"
mock_config.return_value.embedding_dims = 256
config = mock_config()
+100
View File
@@ -0,0 +1,100 @@
from unittest.mock import Mock, patch
import pytest
pytest.importorskip("anthropic", reason="anthropic package not installed")
from mem0.configs.llms.anthropic import AnthropicConfig
from mem0.configs.llms.base import BaseLlmConfig
from mem0.llms.anthropic import AnthropicLLM
@pytest.fixture
def mock_anthropic_client():
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
yield mock_client
def test_default_config_omits_top_p(mock_anthropic_client):
"""Default AnthropicConfig should not set top_p to avoid conflict with temperature."""
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
assert config.top_p is None
assert config.temperature == 0.1
def test_generate_response_does_not_send_top_p_by_default(mock_anthropic_client):
"""Anthropic API rejects temperature and top_p together; top_p must be omitted by default."""
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
llm = AnthropicLLM(config)
mock_response = Mock()
mock_response.content = [Mock(text="Hello!")]
mock_anthropic_client.messages.create.return_value = mock_response
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hi"},
]
llm.generate_response(messages)
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
assert "top_p" not in call_kwargs
assert call_kwargs["temperature"] == 0.1
def test_generate_response_sends_top_p_alone_when_no_temperature(mock_anthropic_client):
"""When user sets only top_p (no temperature), top_p should be sent."""
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=None)
llm = AnthropicLLM(config)
mock_response = Mock()
mock_response.content = [Mock(text="Hello!")]
mock_anthropic_client.messages.create.return_value = mock_response
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hi"},
]
llm.generate_response(messages)
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
assert call_kwargs["top_p"] == 0.9
assert "temperature" not in call_kwargs
def test_both_set_prefers_temperature_over_top_p(mock_anthropic_client):
"""When both temperature and top_p are set, temperature wins and top_p is dropped."""
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=0.5)
llm = AnthropicLLM(config)
mock_response = Mock()
mock_response.content = [Mock(text="Hello!")]
mock_anthropic_client.messages.create.return_value = mock_response
messages = [{"role": "user", "content": "Hi"}]
llm.generate_response(messages)
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
assert call_kwargs["temperature"] == 0.5
assert "top_p" not in call_kwargs
def test_base_config_conversion_does_not_send_both(mock_anthropic_client):
"""BaseLlmConfig defaults both temperature=0.1 and top_p=0.1; Anthropic must not send both."""
base_config = BaseLlmConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
llm = AnthropicLLM(base_config)
mock_response = Mock()
mock_response.content = [Mock(text="Hello!")]
mock_anthropic_client.messages.create.return_value = mock_response
messages = [{"role": "user", "content": "Hi"}]
llm.generate_response(messages)
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
assert "temperature" in call_kwargs
assert "top_p" not in call_kwargs
+426
View File
@@ -0,0 +1,426 @@
from unittest.mock import MagicMock, patch
import pytest
from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
from mem0.llms.aws_bedrock import AWSBedrockLLM, extract_provider
from mem0.utils.factory import LlmFactory
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_boto3():
"""Patch boto3 so no real AWS calls are made during unit tests."""
with patch("mem0.llms.aws_bedrock.boto3") as mock_b3:
runtime_client = MagicMock()
bedrock_client = MagicMock()
bedrock_client.list_foundation_models.return_value = {"modelSummaries": []}
def _client(service, **kwargs):
if service == "bedrock-runtime":
return runtime_client
return bedrock_client
mock_b3.client.side_effect = _client
yield runtime_client
def _make_llm(model: str, mock_boto3, **kwargs) -> AWSBedrockLLM:
"""Instantiate AWSBedrockLLM with a given model, all AWS calls mocked."""
config = AWSBedrockConfig(model=model, **kwargs)
return AWSBedrockLLM(config)
def _converse_response(text: str = "ok") -> dict:
"""Minimal Converse API response dict."""
return {"output": {"message": {"content": [{"text": text}]}}}
# ---------------------------------------------------------------------------
# extract_provider
# ---------------------------------------------------------------------------
class TestExtractProvider:
def test_standard_anthropic_model(self):
assert extract_provider("anthropic.claude-3-5-sonnet-20240620-v1:0") == "anthropic"
def test_inference_profile_us_prefix(self):
# Cross-region inference profile IDs look like us.anthropic.<model>
assert extract_provider("us.anthropic.claude-haiku-4-5-20251001-v1:0") == "anthropic"
def test_inference_profile_eu_prefix(self):
assert extract_provider("eu.anthropic.claude-sonnet-4-5-20250929-v1:0") == "anthropic"
def test_inference_profile_ap_prefix(self):
assert extract_provider("ap.anthropic.claude-3-opus-20240229-v1:0") == "anthropic"
def test_amazon_model(self):
assert extract_provider("amazon.nova-3-mini-20241119-v1:0") == "amazon"
def test_meta_model(self):
assert extract_provider("meta.llama3-8b-instruct-v1:0") == "meta"
def test_mistral_model(self):
assert extract_provider("mistral.mistral-7b-instruct-v0:2") == "mistral"
def test_unknown_model_raises(self):
with pytest.raises(ValueError, match="Unknown provider"):
extract_provider("unknown-vendor.some-model-v1:0")
# ---------------------------------------------------------------------------
# AWSBedrockConfig
# ---------------------------------------------------------------------------
class TestAWSBedrockConfig:
def test_top_p_defaults_to_none(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
assert config.top_p is None
def test_top_p_explicit_value_stored(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", top_p=0.8)
assert config.top_p == 0.8
def test_get_model_config_excludes_top_p_by_default(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", temperature=0.5)
model_cfg = config.get_model_config()
assert "top_p" not in model_cfg
def test_get_model_config_includes_top_p_when_set(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", top_p=0.7)
model_cfg = config.get_model_config()
assert model_cfg["top_p"] == 0.7
def test_get_model_config_top_p_via_model_kwargs(self):
"""model_kwargs can supply top_p after merge; same semantics as explicit top_p."""
config = AWSBedrockConfig(
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
model_kwargs={"top_p": 0.88},
)
assert config.get_model_config()["top_p"] == 0.88
def test_get_model_config_always_includes_temperature(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", temperature=0.3)
model_cfg = config.get_model_config()
assert model_cfg["temperature"] == 0.3
def test_aws_region_stored(self):
config = AWSBedrockConfig(
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
aws_region="us-east-2",
)
assert config.aws_region == "us-east-2"
# ---------------------------------------------------------------------------
# LlmFactory
# ---------------------------------------------------------------------------
class TestLlmFactory:
def test_aws_bedrock_uses_aws_bedrock_config(self):
_, config_class = LlmFactory.provider_to_class["aws_bedrock"]
assert config_class is AWSBedrockConfig
def test_factory_create_accepts_aws_region(self, mock_boto3):
"""LlmFactory.create must not crash when aws_region is in the config dict.
Before the fix, the factory mapped aws_bedrock to BaseLlmConfig which has
no aws_region parameter, causing: TypeError: __init__() got an unexpected
keyword argument 'aws_region'.
"""
user_dict = {
"model": "anthropic.claude-3-5-sonnet-20240620-v1:0",
"aws_region": "us-east-2",
"temperature": 0.1,
"max_tokens": 2000,
}
mock_boto3.converse.return_value = _converse_response()
# This must not raise TypeError
llm = LlmFactory.create("aws_bedrock", user_dict)
assert isinstance(llm.config, AWSBedrockConfig)
assert llm.config.aws_region == "us-east-2"
assert llm.config.top_p is None
# ---------------------------------------------------------------------------
# _build_inference_config
# ---------------------------------------------------------------------------
class TestBuildInferenceConfig:
"""
Unit tests for the _build_inference_config helper.
Validates the exact keys present in the returned dict.
"""
def test_anthropic_only_temperature_by_default(self, mock_boto3):
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.5)
cfg = llm._build_inference_config()
assert "temperature" in cfg
assert cfg["temperature"] == 0.5
assert "topP" not in cfg, "topP must be absent when top_p not configured"
def test_anthropic_top_p_explicitly_set_still_omits_top_p(self, mock_boto3):
# Anthropic rejects both; topP must still be omitted even when set
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
temperature=0.5,
top_p=0.9,
)
cfg = llm._build_inference_config()
assert "temperature" in cfg
assert "topP" not in cfg
def test_anthropic_inference_profile_omits_top_p(self, mock_boto3):
# Cross-region inference profiles (us.anthropic.*) follow the same rule
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3, temperature=0.1)
cfg = llm._build_inference_config()
assert "topP" not in cfg
def test_amazon_includes_top_p_when_set(self, mock_boto3):
llm = _make_llm(
"amazon.nova-3-mini-20241119-v1:0",
mock_boto3,
temperature=0.5,
top_p=0.85,
)
cfg = llm._build_inference_config()
assert cfg["temperature"] == 0.5
assert cfg["topP"] == 0.85
def test_amazon_omits_top_p_when_not_set(self, mock_boto3):
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3, temperature=0.5)
cfg = llm._build_inference_config()
assert "topP" not in cfg
def test_max_tokens_present(self, mock_boto3):
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
max_tokens=1024,
)
cfg = llm._build_inference_config()
assert cfg["maxTokens"] == 1024
def test_nova_fallback_max_tokens_when_absent(self, mock_boto3):
"""Legacy Nova Converse used 5000 when max_tokens was missing from the dict."""
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3)
llm.model_config.pop("max_tokens", None)
cfg = llm._build_inference_config()
assert cfg["maxTokens"] == 5000
def test_anthropic_fallback_max_tokens_when_absent(self, mock_boto3):
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
llm.model_config.pop("max_tokens", None)
cfg = llm._build_inference_config()
assert cfg["maxTokens"] == 2000
def test_minimax_omits_top_p_when_explicitly_set(self, mock_boto3):
# MiniMax M2.x (reasoning model) rejects both temperature and topP simultaneously.
# Even when the user explicitly configures top_p, it must be omitted.
llm = _make_llm(
"minimax.minimax-m2.5",
mock_boto3,
temperature=0.1,
top_p=0.9,
)
cfg = llm._build_inference_config()
assert "temperature" in cfg
assert "topP" not in cfg, "topP must be absent for MiniMax reasoning models"
def test_minimax_only_temperature_by_default(self, mock_boto3):
llm = _make_llm("minimax.minimax-m2.5", mock_boto3, temperature=0.1)
cfg = llm._build_inference_config()
assert cfg["temperature"] == 0.1
assert "topP" not in cfg
# ---------------------------------------------------------------------------
# generate_response — Converse API call assertions
# ---------------------------------------------------------------------------
MESSAGES = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
]
TOOLS = [
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Store a memory",
"parameters": {
"type": "object",
"properties": {"data": {"type": "string"}},
"required": ["data"],
},
},
}
]
class TestGenerateResponseConverse:
def test_standard_anthropic_no_top_p_in_converse_call(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.2)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
inference_cfg = kwargs["inferenceConfig"]
assert "topP" not in inference_cfg
assert inference_cfg["temperature"] == 0.2
def test_standard_anthropic_inference_profile_no_top_p(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3, temperature=0.1)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_with_tools_anthropic_no_top_p(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.2)
llm.generate_response(MESSAGES, tools=TOOLS)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_with_tools_anthropic_top_p_set_still_omitted(self, mock_boto3):
# Even if user explicitly sets top_p, Anthropic must not receive topP
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
temperature=0.2,
top_p=0.9,
)
llm.generate_response(MESSAGES, tools=TOOLS)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_nova_includes_top_p_when_explicitly_set(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm(
"amazon.nova-3-mini-20241119-v1:0",
mock_boto3,
temperature=0.5,
top_p=0.85,
)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert kwargs["inferenceConfig"]["topP"] == 0.85
def test_nova_omits_top_p_when_not_set(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3, temperature=0.5)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_anthropic_model_kwargs_top_p_still_omits_top_p_in_converse(self, mock_boto3):
"""top_p injected via model_kwargs must not add topP for Anthropic Converse."""
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
temperature=0.2,
model_kwargs={"top_p": 0.88},
)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
# ---------------------------------------------------------------------------
# MiniMax provider
# ---------------------------------------------------------------------------
class TestMiniMaxProvider:
"""Tests for MiniMax models via Bedrock Converse API."""
def test_extract_provider(self):
assert extract_provider("minimax.minimax-m2.5") == "minimax"
assert extract_provider("minimax.minimax-m2") == "minimax"
def test_generate_response_text_only(self, mock_boto3):
"""Standard response: single text block."""
mock_boto3.converse.return_value = _converse_response("Hello!")
llm = _make_llm("minimax.minimax-m2.5", mock_boto3)
result = llm.generate_response([{"role": "user", "content": "say hi"}])
assert result == "Hello!"
_, kwargs = mock_boto3.converse.call_args
assert kwargs["modelId"] == "minimax.minimax-m2.5"
assert kwargs["messages"][0]["role"] == "user"
assert kwargs["messages"][0]["content"][0]["text"] == "say hi"
def test_generate_response_reasoning_model(self, mock_boto3):
"""MiniMax M2.5 is a reasoning model: reasoningContent block comes before text."""
reasoning_response = {
"output": {
"message": {
"content": [
{"reasoningContent": {"reasoningText": {"text": "Let me think..."}}},
{"text": "Hello!"},
]
}
}
}
mock_boto3.converse.return_value = reasoning_response
llm = _make_llm("minimax.minimax-m2.5", mock_boto3)
result = llm.generate_response([{"role": "user", "content": "say hi"}])
# Must skip reasoningContent and return the actual text block
assert result == "Hello!"
def test_inference_config(self, mock_boto3):
"""inferenceConfig should include maxTokens and temperature; no topP."""
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("minimax.minimax-m2.5", mock_boto3, temperature=0.2, max_tokens=512)
llm.generate_response([{"role": "user", "content": "hi"}])
_, kwargs = mock_boto3.converse.call_args
assert kwargs["inferenceConfig"]["maxTokens"] == 512
assert kwargs["inferenceConfig"]["temperature"] == 0.2
assert "topP" not in kwargs["inferenceConfig"]
def test_system_prompt_passed_correctly(self, mock_boto3):
"""System messages must be sent via top-level `system` param, not as a message role."""
mock_boto3.converse.return_value = _converse_response('{"facts": ["test"]}')
llm = _make_llm("minimax.minimax-m2.5", mock_boto3)
llm.generate_response([
{"role": "system", "content": "Return JSON only."},
{"role": "user", "content": "Extract facts from: test"},
])
_, kwargs = mock_boto3.converse.call_args
# system prompt must be in top-level "system" key
assert "system" in kwargs
assert kwargs["system"][0]["text"] == "Return JSON only."
# messages list must NOT contain a system role entry
for msg in kwargs["messages"]:
assert msg["role"] != "system"
# user message must be present
assert kwargs["messages"][0]["role"] == "user"
+95
View File
@@ -91,6 +91,101 @@ def test_generate_response_with_tools(mock_openai_client):
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
def test_generate_response_with_response_format(mock_openai_client):
config = AzureOpenAIConfig(model=MODEL, temperature=TEMPERATURE, max_tokens=MAX_TOKENS, top_p=TOP_P)
llm = AzureOpenAILLM(config)
messages = [
{"role": "system", "content": "You are a memory extraction assistant."},
{"role": "user", "content": "I like hiking on weekends."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"facts": ["User likes hiking on weekends"]}'))]
mock_openai_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages, response_format={"type": "json_object"})
mock_openai_client.chat.completions.create.assert_called_once_with(
model=MODEL,
messages=messages,
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
top_p=TOP_P,
response_format={"type": "json_object"},
)
assert response == '{"facts": ["User likes hiking on weekends"]}'
def test_generate_response_without_response_format(mock_openai_client):
config = AzureOpenAIConfig(model=MODEL, temperature=TEMPERATURE, max_tokens=MAX_TOKENS, top_p=TOP_P)
llm = AzureOpenAILLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a joke."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Why did the chicken cross the road?"))]
mock_openai_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_openai_client.chat.completions.create.call_args[1]
assert "response_format" not in call_kwargs
assert response == "Why did the chicken cross the road?"
def test_reasoning_model_with_reasoning_effort(mock_openai_client):
"""Test that reasoning_effort is passed to the API for Azure reasoning models."""
config = AzureOpenAIConfig(model="o3-mini", reasoning_effort="low")
llm = AzureOpenAILLM(config)
messages = [
{"role": "system", "content": "You are a helpful ai."},
{"role": "user", "content": "Hello"},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Response from o3-mini"))]
mock_openai_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_openai_client.chat.completions.create.call_args
assert call_kwargs[1]["reasoning_effort"] == "low"
assert "temperature" not in call_kwargs[1]
assert response == "Response from o3-mini"
def test_azure_reasoning_effort_not_passed_when_none(mock_openai_client):
"""Test that reasoning_effort is not passed when not configured on Azure."""
config = AzureOpenAIConfig(model="o3-mini")
llm = AzureOpenAILLM(config)
messages = [
{"role": "system", "content": "You are a helpful ai."},
{"role": "user", "content": "Hello"},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Response"))]
mock_openai_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages)
call_kwargs = mock_openai_client.chat.completions.create.call_args
assert "reasoning_effort" not in call_kwargs[1]
def test_azure_config_accepts_reasoning_effort():
"""Test that AzureOpenAIConfig accepts reasoning_effort without TypeError (issue #3651)."""
config = AzureOpenAIConfig(
model="o3-mini",
reasoning_effort="low",
azure_kwargs={"api_key": "test"},
)
assert config.reasoning_effort == "low"
assert config.model == "o3-mini"
@pytest.mark.parametrize(
"default_headers",
[None, {"Firstkey": "FirstVal", "SecondKey": "SecondVal"}],
@@ -1,4 +1,5 @@
from unittest import mock
from unittest.mock import Mock
from mem0.llms.azure_openai_structured import SCOPE, AzureOpenAIStructuredLLM
@@ -98,3 +99,75 @@ def test_init_with_placeholder_api_key_uses_default_credential(
args, kwargs = mock_azure_openai.call_args
assert kwargs["api_key"] is None
assert kwargs["azure_ad_token_provider"] == "token-provider"
@mock.patch("mem0.llms.azure_openai_structured.AzureOpenAI")
def test_generate_response_without_tools(mock_azure_openai):
mock_client = Mock()
mock_azure_openai.return_value = mock_client
config = DummyConfig(model="test-model", azure_kwargs=DummyAzureKwargs(api_key="real-key"))
llm = AzureOpenAIStructuredLLM(config)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Hello there!"))]
mock_client.chat.completions.create.return_value = mock_response
messages = [{"role": "user", "content": "Hi"}]
response = llm.generate_response(messages)
assert response == "Hello there!"
@mock.patch("mem0.llms.azure_openai_structured.AzureOpenAI")
def test_generate_response_with_tools(mock_azure_openai):
mock_client = Mock()
mock_azure_openai.return_value = mock_client
config = DummyConfig(model="test-model", azure_kwargs=DummyAzureKwargs(api_key="real-key"))
llm = AzureOpenAIStructuredLLM(config)
mock_tool_call = Mock()
mock_tool_call.function.name = "add_memory"
mock_tool_call.function.arguments = '{"data": "sunny day"}'
mock_message = Mock()
mock_message.content = "I've added the memory."
mock_message.tool_calls = [mock_tool_call]
mock_response = Mock()
mock_response.choices = [Mock(message=mock_message)]
mock_client.chat.completions.create.return_value = mock_response
tools = [{"type": "function", "function": {"name": "add_memory"}}]
messages = [{"role": "user", "content": "Remember sunny day"}]
response = llm.generate_response(messages, tools=tools)
assert response["content"] == "I've added the memory."
assert len(response["tool_calls"]) == 1
assert response["tool_calls"][0]["name"] == "add_memory"
assert response["tool_calls"][0]["arguments"] == {"data": "sunny day"}
@mock.patch("mem0.llms.azure_openai_structured.AzureOpenAI")
def test_generate_response_with_tools_no_tool_calls(mock_azure_openai):
mock_client = Mock()
mock_azure_openai.return_value = mock_client
config = DummyConfig(model="test-model", azure_kwargs=DummyAzureKwargs(api_key="real-key"))
llm = AzureOpenAIStructuredLLM(config)
mock_message = Mock()
mock_message.content = "No tools needed."
mock_message.tool_calls = None
mock_response = Mock()
mock_response.choices = [Mock(message=mock_message)]
mock_client.chat.completions.create.return_value = mock_response
tools = [{"type": "function", "function": {"name": "add_memory"}}]
messages = [{"role": "user", "content": "Hello"}]
response = llm.generate_response(messages, tools=tools)
assert response["content"] == "No tools needed."
assert response["tool_calls"] == []
+44
View File
@@ -113,3 +113,47 @@ def test_generate_response_with_tools(mock_deepseek_client):
assert len(response["tool_calls"]) == 1
assert response["tool_calls"][0]["name"] == "add_memory"
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
def test_generate_response_with_response_format(mock_deepseek_client):
config = BaseLlmConfig(model="deepseek-chat", temperature=0.7, max_tokens=100, top_p=1.0)
llm = DeepSeekLLM(config)
messages = [
{"role": "system", "content": "You are a memory extraction assistant."},
{"role": "user", "content": "I like hiking on weekends."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"facts": ["User likes hiking on weekends"]}'))]
mock_deepseek_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages, response_format={"type": "json_object"})
mock_deepseek_client.chat.completions.create.assert_called_once_with(
model="deepseek-chat",
messages=messages,
temperature=0.7,
max_tokens=100,
top_p=1.0,
response_format={"type": "json_object"},
)
assert response == '{"facts": ["User likes hiking on weekends"]}'
def test_generate_response_without_response_format(mock_deepseek_client):
config = BaseLlmConfig(model="deepseek-chat", temperature=0.7, max_tokens=100, top_p=1.0)
llm = DeepSeekLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a joke."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Why did the chicken cross the road?"))]
mock_deepseek_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_deepseek_client.chat.completions.create.call_args[1]
assert "response_format" not in call_kwargs
assert response == "Why did the chicken cross the road?"
+70
View File
@@ -117,3 +117,73 @@ def test_generate_response_with_tools(mock_gemini_client: Mock):
assert len(response["tool_calls"]) == 1
assert response["tool_calls"][0]["name"] == "add_memory"
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
def test_parse_response_none_content_no_tools(mock_gemini_client: Mock):
"""Gemini can return content=None when response is blocked by safety filters."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_candidate = Mock(content=None)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_none_content_with_tools(mock_gemini_client: Mock):
"""Gemini can return content=None when response is blocked by safety filters (tools path)."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_candidate = Mock(content=None)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=[{"function": {"name": "test"}}])
assert result == {"content": None, "tool_calls": []}
def test_parse_response_empty_candidates(mock_gemini_client: Mock):
"""Gemini can return an empty candidates list."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_response = Mock(candidates=[])
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_none_candidates(mock_gemini_client: Mock):
"""Gemini can return candidates=None."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_response = Mock(candidates=None)
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_empty_parts_no_tools(mock_gemini_client: Mock):
"""Gemini can return content with an empty parts list."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_content = Mock(parts=[])
mock_candidate = Mock(content=mock_content)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_empty_parts_with_tools(mock_gemini_client: Mock):
"""Gemini can return content with an empty parts list (tools path)."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_content = Mock(parts=[])
mock_candidate = Mock(content=mock_content)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=[{"function": {"name": "test"}}])
assert result == {"content": None, "tool_calls": []}
+194
View File
@@ -0,0 +1,194 @@
import os
from unittest.mock import Mock, patch
import pytest
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.minimax import MinimaxConfig
from mem0.llms.minimax import MiniMaxLLM
from mem0.utils.factory import LlmFactory
@pytest.fixture
def mock_minimax_client():
with patch("mem0.llms.minimax.OpenAI") as mock_openai:
mock_client = Mock()
mock_openai.return_value = mock_client
yield mock_client
def test_minimax_llm_default_base_url():
"""Default config uses MiniMax official base URL."""
config = BaseLlmConfig(
model="MiniMax-M2.7", temperature=0.7, max_tokens=100, top_p=1.0, api_key="api_key"
)
llm = MiniMaxLLM(config)
# OpenAI client may normalize URL with trailing slash
assert str(llm.client.base_url).rstrip("/") == "https://api.minimax.io/v1"
def test_minimax_llm_env_base_url():
"""Config uses MINIMAX_API_BASE env variable when set."""
provider_base_url = "https://api.provider.com/v1/"
os.environ["MINIMAX_API_BASE"] = provider_base_url
try:
config = MinimaxConfig(
model="MiniMax-M2.7",
temperature=0.7,
max_tokens=100,
top_p=1.0,
api_key="api_key",
)
llm = MiniMaxLLM(config)
assert str(llm.client.base_url).rstrip("/") == provider_base_url.rstrip("/")
finally:
os.environ.pop("MINIMAX_API_BASE", None)
def test_minimax_llm_config_base_url():
"""Config uses minimax_base_url when provided."""
config_base_url = "https://api.config.com/v1/"
config = MinimaxConfig(
model="MiniMax-M2.7",
temperature=0.7,
max_tokens=100,
top_p=1.0,
api_key="api_key",
minimax_base_url=config_base_url,
)
llm = MiniMaxLLM(config)
assert str(llm.client.base_url).rstrip("/") == config_base_url.rstrip("/")
def test_minimax_llm_default_model(mock_minimax_client):
"""Default model is MiniMax-M2.7 when not specified."""
config = MinimaxConfig(temperature=0.7, max_tokens=100, api_key="api_key")
llm = MiniMaxLLM(config)
assert llm.config.model == "MiniMax-M2.7"
def test_minimax_llm_env_api_key():
"""Uses MINIMAX_API_KEY env when api_key not in config."""
os.environ["MINIMAX_API_KEY"] = "env-api-key"
try:
with patch("mem0.llms.minimax.OpenAI") as mock_openai:
mock_client = Mock()
mock_openai.return_value = mock_client
config = MinimaxConfig(model="MiniMax-M2.7", api_key=None)
MiniMaxLLM(config)
mock_openai.assert_called_once_with(
api_key="env-api-key",
base_url="https://api.minimax.io/v1",
)
finally:
os.environ.pop("MINIMAX_API_KEY", None)
def test_generate_response_without_tools(mock_minimax_client):
"""generate_response returns text when no tools provided."""
config = BaseLlmConfig(
model="MiniMax-M2.7", temperature=0.7, max_tokens=100, top_p=1.0, api_key="api_key"
)
llm = MiniMaxLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello, how are you?"},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="I'm doing well, thank you for asking!"))]
mock_minimax_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
mock_minimax_client.chat.completions.create.assert_called_once_with(
model="MiniMax-M2.7", messages=messages, temperature=0.7, max_tokens=100, top_p=1.0
)
assert response == "I'm doing well, thank you for asking!"
def test_generate_response_with_tools(mock_minimax_client):
"""generate_response returns tool_calls when tools provided."""
config = BaseLlmConfig(
model="MiniMax-M2.7", temperature=0.7, max_tokens=100, top_p=1.0, api_key="api_key"
)
llm = MiniMaxLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Add a new memory: Today is a sunny day."},
]
tools = [
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Add a memory",
"parameters": {
"type": "object",
"properties": {"data": {"type": "string", "description": "Data to add to memory"}},
"required": ["data"],
},
},
}
]
mock_response = Mock()
mock_message = Mock()
mock_message.content = "I've added the memory for you."
mock_tool_call = Mock()
mock_tool_call.function.name = "add_memory"
mock_tool_call.function.arguments = '{"data": "Today is a sunny day."}'
mock_message.tool_calls = [mock_tool_call]
mock_response.choices = [Mock(message=mock_message)]
mock_minimax_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
mock_minimax_client.chat.completions.create.assert_called_once_with(
model="MiniMax-M2.7",
messages=messages,
temperature=0.7,
max_tokens=100,
top_p=1.0,
tools=tools,
tool_choice="auto",
)
assert response["content"] == "I've added the memory for you."
assert len(response["tool_calls"]) == 1
assert response["tool_calls"][0]["name"] == "add_memory"
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
def test_generate_response_with_response_format(mock_minimax_client):
"""generate_response passes response_format to the API."""
config = BaseLlmConfig(
model="MiniMax-M2.7", temperature=0.7, max_tokens=100, top_p=1.0, api_key="api_key"
)
llm = MiniMaxLLM(config)
messages = [{"role": "user", "content": "Return JSON."}]
response_format = {"type": "json_object"}
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"key": "value"}'))]
mock_minimax_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages, response_format=response_format)
mock_minimax_client.chat.completions.create.assert_called_once_with(
model="MiniMax-M2.7",
messages=messages,
temperature=0.7,
max_tokens=100,
top_p=1.0,
response_format={"type": "json_object"},
)
def test_factory_creates_minimax_llm(mock_minimax_client):
"""LlmFactory.create returns MiniMaxLLM for provider 'minimax'."""
llm = LlmFactory.create("minimax", {"model": "MiniMax-M2.7", "api_key": "test-key"})
assert isinstance(llm, MiniMaxLLM)
assert llm.config.model == "MiniMax-M2.7"
+108
View File
@@ -32,3 +32,111 @@ def test_generate_response_without_tools(mock_ollama_client):
model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0}
)
assert response == "I'm doing well, thank you for asking!"
def test_generate_response_with_tools_passes_tools_to_client(mock_ollama_client):
"""Tools should be forwarded to ollama client.chat()."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "Extract entities from: Alice works at UCSD"}]
tools = [
{
"type": "function",
"function": {
"name": "extract_entities",
"description": "Extract entities",
"parameters": {"type": "object", "properties": {"entities": {"type": "array"}}},
},
}
]
mock_response = {
"message": {
"content": "",
"tool_calls": [
{
"function": {
"name": "extract_entities",
"arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]},
}
}
],
}
}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
# Verify tools were passed to client.chat
call_kwargs = mock_ollama_client.chat.call_args
assert "tools" in call_kwargs.kwargs or (len(call_kwargs.args) > 0 and "tools" in call_kwargs[1])
assert call_kwargs[1]["tools"] == tools
# Verify tool_calls were parsed correctly
assert response["tool_calls"] == [
{"name": "extract_entities", "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}}
]
def test_generate_response_with_tools_no_tool_calls_in_response(mock_ollama_client):
"""When model returns content without tool_calls, tool_calls should be empty list."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "Hello"}]
tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}]
mock_response = {"message": {"content": "I cannot use tools for this.", "tool_calls": []}}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
assert response["content"] == "I cannot use tools for this."
assert response["tool_calls"] == []
def test_generate_response_with_tools_string_arguments(mock_ollama_client):
"""When tool_call arguments come as JSON string, they should be parsed."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "test"}]
tools = [{"type": "function", "function": {"name": "test_fn", "parameters": {}}}]
mock_response = {
"message": {
"content": "",
"tool_calls": [
{"function": {"name": "test_fn", "arguments": '{"key": "value"}'}}
],
}
}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
assert response["tool_calls"] == [{"name": "test_fn", "arguments": {"key": "value"}}]
def test_parse_response_with_tools_object_style(mock_ollama_client):
"""Test _parse_response with object-style response (non-dict)."""
config = OllamaConfig(model="llama3.1:70b")
llm = OllamaLLM(config)
# Simulate object-style response
mock_fn = Mock()
mock_fn.name = "extract"
mock_fn.arguments = {"entities": ["Alice"]}
mock_tool_call = Mock()
mock_tool_call.function = mock_fn
mock_message = Mock()
mock_message.content = ""
mock_message.tool_calls = [mock_tool_call]
mock_response = Mock()
mock_response.message = mock_message
tools = [{"type": "function", "function": {"name": "extract"}}]
result = llm._parse_response(mock_response, tools)
assert result["tool_calls"] == [{"name": "extract", "arguments": {"entities": ["Alice"]}}]
+61
View File
@@ -170,6 +170,67 @@ def test_callback_exception_handling(mock_openai_client):
assert llm.config.response_callback is faulty_callback
def test_reasoning_model_with_reasoning_effort(mock_openai_client):
"""Test that reasoning_effort is passed to the API for reasoning models."""
config = OpenAIConfig(model="o3-mini", reasoning_effort="low")
llm = OpenAILLM(config)
messages = [{"role": "user", "content": "Hello"}]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Response from o3-mini"))]
mock_openai_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_openai_client.chat.completions.create.call_args
assert call_kwargs[1]["reasoning_effort"] == "low"
assert "temperature" not in call_kwargs[1] # reasoning models don't get temperature
assert response == "Response from o3-mini"
def test_reasoning_model_without_reasoning_effort(mock_openai_client):
"""Test that reasoning_effort is not passed when not configured."""
config = OpenAIConfig(model="o3-mini")
llm = OpenAILLM(config)
messages = [{"role": "user", "content": "Hello"}]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Response"))]
mock_openai_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages)
call_kwargs = mock_openai_client.chat.completions.create.call_args
assert "reasoning_effort" not in call_kwargs[1]
def test_non_reasoning_model_ignores_reasoning_effort(mock_openai_client):
"""Test that reasoning_effort is not passed for non-reasoning models."""
config = OpenAIConfig(model="gpt-4.1-nano-2025-04-14", reasoning_effort="high")
llm = OpenAILLM(config)
messages = [{"role": "user", "content": "Hello"}]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Response"))]
mock_openai_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages)
call_kwargs = mock_openai_client.chat.completions.create.call_args
# Non-reasoning models use common params path, reasoning_effort not added there
assert "reasoning_effort" not in call_kwargs[1]
def test_reasoning_effort_config_values():
"""Test that reasoning_effort can be set to all valid values."""
for effort in ["low", "medium", "high"]:
config = OpenAIConfig(model="o3", reasoning_effort=effort)
assert config.reasoning_effort == effort
config = OpenAIConfig(model="o3")
assert config.reasoning_effort is None
def test_callback_with_tools(mock_openai_client):
mock_callback = Mock()
config = OpenAIConfig(model="gpt-4.1-nano-2025-04-14", response_callback=mock_callback)
+44
View File
@@ -88,6 +88,50 @@ def test_generate_response_with_tools(mock_vllm_client):
def test_generate_response_with_response_format(mock_vllm_client):
config = BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct", temperature=0.7, max_tokens=100, top_p=1.0)
llm = VllmLLM(config)
messages = [
{"role": "system", "content": "You are a memory extraction assistant."},
{"role": "user", "content": "I like hiking on weekends."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"facts": ["User likes hiking on weekends"]}'))]
mock_vllm_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages, response_format={"type": "json_object"})
mock_vllm_client.chat.completions.create.assert_called_once_with(
model="Qwen/Qwen2.5-32B-Instruct",
messages=messages,
temperature=0.7,
max_tokens=100,
top_p=1.0,
response_format={"type": "json_object"},
)
assert response == '{"facts": ["User likes hiking on weekends"]}'
def test_generate_response_without_response_format(mock_vllm_client):
config = BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct", temperature=0.7, max_tokens=100, top_p=1.0)
llm = VllmLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a joke."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Why did the chicken cross the road?"))]
mock_vllm_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_vllm_client.chat.completions.create.call_args[1]
assert "response_format" not in call_kwargs
assert response == "Why did the chicken cross the road?"
def create_mocked_memory():
"""Create a fully mocked Memory instance for testing."""
with patch('mem0.utils.factory.LlmFactory.create') as mock_llm_factory, \
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
from unittest.mock import MagicMock, Mock, patch
# age and rank_bm25 are optional deps — mock them so tests run without install
_age_mock = Mock()
patch.dict("sys.modules", {
"age": _age_mock,
"age.models": Mock(),
"rank_bm25": Mock(),
}).start()
from mem0.memory.apache_age_memory import MemoryGraph, _cosine_similarity # noqa: E402
def _make_instance():
with patch.object(MemoryGraph, "__init__", return_value=None):
instance = MemoryGraph.__new__(MemoryGraph)
instance.llm_provider = "openai"
instance.llm = MagicMock()
instance.embedding_model = MagicMock()
instance.config = MagicMock()
instance.config.graph_store.custom_prompt = None
instance.ag = MagicMock()
instance.graph_name = "test_graph"
instance.threshold = 0.7
return instance
class TestCosineSimilarity:
"""Tests for the _cosine_similarity helper."""
def test_identical_vectors(self):
assert abs(_cosine_similarity([1, 0, 0], [1, 0, 0]) - 1.0) < 1e-6
def test_orthogonal_vectors(self):
assert abs(_cosine_similarity([1, 0, 0], [0, 1, 0])) < 1e-6
def test_zero_vector(self):
assert _cosine_similarity([0, 0, 0], [1, 2, 3]) == 0.0
class TestRetrieveNodesFromData:
"""Tests for _retrieve_nodes_from_data in Apache AGE MemoryGraph."""
def test_normal_entities_extracted(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "Alice", "entity_type": "person"},
{"entity": "hiking", "entity_type": "activity"},
]}}]
}
result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"})
assert result == {"alice": "person", "hiking": "activity"}
def test_malformed_entity_missing_entity_type_is_skipped(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "matrix multiplication", "entity_type": "task"},
{"entity": "task"},
{"entity": "ReLU", "entity_type": "task"},
]}}]
}
result = instance._retrieve_nodes_from_data("some text", {"user_id": "u1"})
assert "matrix_multiplication" in result
assert "relu" in result
assert "task" not in result
def test_missing_entities_key_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}]
}
result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"})
assert result == {}
def test_none_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"})
assert result == {}
class TestEstablishNodesRelationsFromData:
"""Tests for _establish_nodes_relations_from_data in Apache AGE MemoryGraph."""
def test_none_response_does_not_crash(self):
instance = _make_instance()
instance.llm.generate_response.return_value = None
result = instance._establish_nodes_relations_from_data(
"Hello world", {"user_id": "u1"}, {}
)
assert result == []
def test_empty_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": []}
result = instance._establish_nodes_relations_from_data(
"Hello world", {"user_id": "u1"}, {}
)
assert result == []
def test_valid_entities_returned(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "add_entities", "arguments": {"entities": [
{"source": "alice", "relationship": "loves", "destination": "hiking"}
]}}]
}
result = instance._establish_nodes_relations_from_data(
"Alice loves hiking", {"user_id": "u1"}, {"alice": "person"}
)
assert len(result) == 1
assert result[0]["source"] == "alice"
class TestRemoveSpacesFromEntities:
"""Tests for _remove_spaces_from_entities."""
def test_spaces_and_case(self):
instance = _make_instance()
entities = [{"source": "Alice Smith", "relationship": "Works At", "destination": "Big Corp"}]
result = instance._remove_spaces_from_entities(entities)
assert result[0]["source"] == "alice_smith"
assert result[0]["relationship"] == "works_at"
assert result[0]["destination"] == "big_corp"
class TestFindSimilarNode:
"""Tests for _find_similar_node."""
def test_returns_none_when_no_nodes(self):
instance = _make_instance()
instance._exec_cypher = MagicMock(return_value=[])
result = instance._find_similar_node([1.0, 0.0], {"user_id": "u1"}, threshold=0.9)
assert result is None
def test_returns_best_match_above_threshold(self):
instance = _make_instance()
instance._exec_cypher = MagicMock(return_value=[
{"name": "alice", "embedding": [1.0, 0.0], "user_id": "u1"},
{"name": "bob", "embedding": [0.0, 1.0], "user_id": "u1"},
])
result = instance._find_similar_node([1.0, 0.0], {"user_id": "u1"}, threshold=0.9)
assert result["name"] == "alice"
def test_filters_by_agent_id(self):
instance = _make_instance()
instance._exec_cypher = MagicMock(return_value=[
{"name": "alice", "embedding": [1.0, 0.0], "user_id": "u1", "agent_id": "a2"},
])
result = instance._find_similar_node(
[1.0, 0.0], {"user_id": "u1", "agent_id": "a1"}, threshold=0.9
)
assert result is None
class TestDeleteAll:
"""Tests for delete_all."""
def test_calls_exec_cypher_and_commits(self):
instance = _make_instance()
instance._exec_cypher = MagicMock(return_value=[])
instance.delete_all({"user_id": "u1"})
instance._exec_cypher.assert_called_once()
instance.ag.commit.assert_called_once()
class TestGetAll:
"""Tests for get_all."""
def test_returns_formatted_results(self):
instance = _make_instance()
instance._exec_cypher = MagicMock(return_value=[
{"source": "alice", "relationship": "KNOWS", "target": "bob"},
{"source": "alice", "relationship": "LIKES", "target": "hiking"},
])
results = instance.get_all({"user_id": "u1"}, top_k=10)
assert len(results) == 2
assert results[0]["source"] == "alice"
assert results[0]["relationship"] == "KNOWS"
assert results[0]["target"] == "bob"
def test_passes_limit_to_cypher(self):
"""Limit is enforced via LIMIT in the Cypher query, not Python slicing."""
instance = _make_instance()
instance._exec_cypher = MagicMock(return_value=[
{"source": "n0", "relationship": "R", "target": "m0"},
])
instance.get_all({"user_id": "u1"}, top_k=3)
# Verify limit was passed as a parameter to the query
cypher_stmt = instance._exec_cypher.call_args[0][0]
assert "LIMIT %s" in cypher_stmt
params = instance._exec_cypher.call_args[1].get("params") or instance._exec_cypher.call_args[0][2]
assert 3 in params
class TestAdd:
"""Tests for the add orchestration method."""
def test_add_returns_added_and_deleted(self):
instance = _make_instance()
instance._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person"})
instance._establish_nodes_relations_from_data = MagicMock(return_value=[
{"source": "alice", "relationship": "knows", "destination": "bob"}
])
instance._search_graph_db = MagicMock(return_value=[])
instance._get_delete_entities_from_search_output = MagicMock(return_value=[])
instance._delete_entities = MagicMock(return_value=[])
instance._add_entities = MagicMock(return_value=["added"])
result = instance.add("Alice knows Bob", {"user_id": "u1"})
assert "deleted_entities" in result
assert "added_entities" in result
assert result["added_entities"] == ["added"]
class TestSearch:
"""Tests for the search method."""
def test_returns_empty_when_no_search_output(self):
instance = _make_instance()
instance._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person"})
instance._search_graph_db = MagicMock(return_value=[])
result = instance.search("Who is Alice?", {"user_id": "u1"})
assert result == []
@@ -0,0 +1,315 @@
"""Tests for graph memory soft-delete behavior.
Verifies that _delete_entities marks relationships as invalid (soft-delete)
rather than permanently removing them, and that search/retrieval queries
exclude soft-deleted relationships by default.
See: https://github.com/mem0ai/mem0/issues/4187
"""
from unittest.mock import Mock, patch
# Mock optional deps at module level so the import works across all Python
# versions without triggering transitive C-extension reloads (numpy via
# qdrant_client). This matches the pattern in test_memgraph_memory.py.
_neo4j_mock = Mock()
patch.dict("sys.modules", {
"langchain_neo4j": _neo4j_mock,
"rank_bm25": Mock(),
}).start()
from mem0.memory.graph_memory import MemoryGraph # noqa: E402
def _create_graph_memory():
"""Create a MemoryGraph instance with mocked dependencies."""
with patch.object(MemoryGraph, "__init__", lambda self, *a, **kw: None):
mg = MemoryGraph.__new__(MemoryGraph)
mg.graph = Mock()
mg.graph.query = Mock(return_value=[])
mg.embedding_model = Mock()
mg.embedding_model.embed = Mock(return_value=[0.1] * 128)
mg.llm = Mock()
mg.node_label = ":Entity"
mg.threshold = 0.7
mg.llm_provider = "openai"
return mg
class TestSoftDelete:
"""Verify _delete_entities uses SET r.valid = false, not DELETE r."""
def test_delete_entities_sends_soft_delete_cypher(self):
mg = _create_graph_memory()
mg.graph.query.return_value = [
{"source": "Alice", "target": "Bob", "relationship": "KNOWS"}
]
mg._delete_entities(
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
{"user_id": "user1"},
)
cypher = mg.graph.query.call_args[0][0]
assert "SET r.valid = false" in cypher
assert "r.invalidated_at = datetime()" in cypher
assert "DELETE r" not in cypher
def test_delete_entities_only_targets_valid_edges(self):
mg = _create_graph_memory()
mg._delete_entities(
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
{"user_id": "user1"},
)
cypher = mg.graph.query.call_args[0][0]
assert "r.valid IS NULL OR r.valid = true" in cypher
def test_delete_entities_is_idempotent(self):
mg = _create_graph_memory()
item = [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}]
filters = {"user_id": "user1"}
mg.graph.query.return_value = [
{"source": "Alice", "target": "Bob", "relationship": "KNOWS"}
]
mg._delete_entities(item, filters)
mg.graph.query.return_value = []
mg._delete_entities(item, filters)
# Both calls should have the same WHERE filter
for c in mg.graph.query.call_args_list:
assert "r.valid IS NULL OR r.valid = true" in c[0][0]
class TestSearchExcludesSoftDeleted:
"""Verify search and get_all filter out soft-deleted relationships."""
def test_get_all_filters_soft_deleted(self):
mg = _create_graph_memory()
mg.get_all(filters={"user_id": "user1"}, top_k=10)
cypher = mg.graph.query.call_args[0][0]
assert "r.valid IS NULL OR r.valid = true" in cypher
def test_search_graph_db_filters_both_directions(self):
"""_search_graph_db must filter soft-deleted edges in both outgoing and incoming queries."""
mg = _create_graph_memory()
mg.graph.query.return_value = []
mg._search_graph_db(node_list=["alice"], filters={"user_id": "user1"})
cypher = mg.graph.query.call_args[0][0]
# The UNION query has two MATCH branches — both must filter
occurrences = cypher.count("r.valid IS NULL OR r.valid = true")
assert occurrences >= 2, (
f"_search_graph_db has {occurrences} valid-filter(s) but needs >= 2 "
"(one for outgoing, one for incoming relationships)"
)
def test_delete_all_still_hard_deletes(self):
mg = _create_graph_memory()
mg.delete_all(filters={"user_id": "user1"})
cypher = mg.graph.query.call_args[0][0]
assert "DETACH DELETE" in cypher
class TestMergeResetsValidFlag:
"""Verify MERGE in _add_entities sets r.valid = true.
Critical: after soft-delete, a MERGE that matches the existing
(invalidated) edge must reset valid=true, or the edge becomes
a zombie -- exists but invisible to queries.
"""
def _run_add_entities(self, source_found, dest_found):
"""Helper: call _add_entities with configurable node search results."""
mg = _create_graph_memory()
source_result = (
[{"elementId(source_candidate)": "src_id_1"}] if source_found else []
)
dest_result = (
[{"elementId(destination_candidate)": "dst_id_1"}] if dest_found else []
)
mg._search_source_node = Mock(return_value=source_result)
mg._search_destination_node = Mock(return_value=dest_result)
mg._add_entities(
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
{"user_id": "user1"},
entity_type_map={},
)
cypher = mg.graph.query.call_args[0][0]
return cypher
def test_merge_sets_valid_true_when_source_found(self):
cypher = self._run_add_entities(source_found=True, dest_found=False)
assert "r.valid = true" in cypher
def test_merge_sets_valid_true_when_dest_found(self):
cypher = self._run_add_entities(source_found=False, dest_found=True)
assert "r.valid = true" in cypher
def test_merge_sets_valid_true_when_both_found(self):
cypher = self._run_add_entities(source_found=True, dest_found=True)
assert "r.valid = true" in cypher
def test_merge_sets_valid_true_when_neither_found(self):
cypher = self._run_add_entities(source_found=False, dest_found=False)
assert "r.valid = true" in cypher
def test_merge_clears_invalidated_at_on_resurrection(self):
"""When a soft-deleted edge is resurrected via MERGE, invalidated_at must be cleared.
Without this, a resurrected edge (valid=true) still carries stale
invalidated_at metadata, which corrupts temporal reasoning queries.
"""
for label, src, dst in [
("source found", True, False),
("dest found", False, True),
("both found", True, True),
("neither found", False, False),
]:
cypher = self._run_add_entities(source_found=src, dest_found=dst)
assert "r.invalidated_at = null" in cypher, (
f"MERGE path '{label}': ON MATCH SET does not clear r.invalidated_at. "
"Resurrected edges will have stale invalidation timestamps."
)
class TestCypherConsistency:
"""Verify all MERGE blocks use consistent property names and variable aliases."""
def _get_merge_cypher(self, source_found, dest_found):
mg = _create_graph_memory()
mg._search_source_node = Mock(
return_value=[{"elementId(source_candidate)": "id1"}] if source_found else []
)
mg._search_destination_node = Mock(
return_value=[{"elementId(destination_candidate)": "id2"}] if dest_found else []
)
mg._add_entities(
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
{"user_id": "user1"},
entity_type_map={},
)
return mg.graph.query.call_args[0][0]
def test_all_blocks_use_created_at_not_created(self):
"""All MERGE blocks must use r.created_at, not r.created."""
for label, src, dst in [
("source found", True, False),
("dest found", False, True),
("both found", True, True),
("neither found", False, False),
]:
cypher = self._get_merge_cypher(src, dst)
assert "r.created_at" in cypher, (
f"MERGE path '{label}': uses r.created instead of r.created_at"
)
def test_all_blocks_use_r_not_rel(self):
"""All MERGE blocks must use 'r' as the relationship variable, not 'rel'."""
for label, src, dst in [
("source found", True, False),
("dest found", False, True),
("both found", True, True),
("neither found", False, False),
]:
cypher = self._get_merge_cypher(src, dst)
assert "rel." not in cypher, (
f"MERGE path '{label}': uses 'rel' variable instead of 'r'"
)
def test_all_blocks_set_updated_at_on_create(self):
"""All MERGE blocks must set r.updated_at on CREATE for consistent timestamps."""
for label, src, dst in [
("source found", True, False),
("dest found", False, True),
("both found", True, True),
("neither found", False, False),
]:
cypher = self._get_merge_cypher(src, dst)
assert "r.updated_at = timestamp()" in cypher, (
f"MERGE path '{label}': missing r.updated_at on CREATE SET"
)
class TestSoftDeleteWithFilters:
"""Verify soft-delete works correctly with agent_id and run_id filters."""
def test_delete_entities_with_agent_id(self):
mg = _create_graph_memory()
mg._delete_entities(
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
{"user_id": "user1", "agent_id": "agent1"},
)
cypher = mg.graph.query.call_args[0][0]
params = mg.graph.query.call_args[1]["params"]
assert "SET r.valid = false" in cypher
assert "agent_id: $agent_id" in cypher
assert params["agent_id"] == "agent1"
def test_delete_entities_with_run_id(self):
mg = _create_graph_memory()
mg._delete_entities(
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
{"user_id": "user1", "run_id": "run1"},
)
cypher = mg.graph.query.call_args[0][0]
params = mg.graph.query.call_args[1]["params"]
assert "SET r.valid = false" in cypher
assert "run_id: $run_id" in cypher
assert params["run_id"] == "run1"
def test_get_all_with_agent_id_filters_soft_deleted(self):
mg = _create_graph_memory()
mg.get_all(filters={"user_id": "user1", "agent_id": "agent1"}, top_k=10)
cypher = mg.graph.query.call_args[0][0]
assert "r.valid IS NULL OR r.valid = true" in cypher
assert "agent_id: $agent_id" in cypher
def test_merge_with_agent_id_sets_valid_true(self):
mg = _create_graph_memory()
mg._search_source_node = Mock(
return_value=[{"elementId(source_candidate)": "id1"}]
)
mg._search_destination_node = Mock(return_value=[])
mg._add_entities(
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
{"user_id": "user1", "agent_id": "agent1"},
entity_type_map={},
)
cypher = mg.graph.query.call_args[0][0]
assert "r.valid = true" in cypher
assert "agent_id: $agent_id" in cypher
class TestResetAndCleanup:
"""Verify reset and delete_all use hard-delete (DETACH DELETE)."""
def test_reset_uses_detach_delete(self):
mg = _create_graph_memory()
mg.reset()
cypher = mg.graph.query.call_args[0][0]
assert "DETACH DELETE" in cypher
assert "valid" not in cypher.lower()
def test_delete_all_does_not_soft_delete(self):
mg = _create_graph_memory()
mg.delete_all(filters={"user_id": "user1"})
cypher = mg.graph.query.call_args[0][0]
assert "DETACH DELETE" in cypher
assert "r.valid = false" not in cypher
+214
View File
@@ -0,0 +1,214 @@
"""
Tests for issue #3559: Custom prompts crash with response_format json_object
when the word 'json' is not present in the prompt.
OpenAI API requires the word 'json' to appear in messages when using
response_format: {"type": "json_object"}. Custom fact extraction prompts
may not include this word, causing BadRequestError.
This tests the ensure_json_instruction utility function and verifies
the fix is applied in both sync and async code paths.
"""
import pytest
from mem0.memory.utils import ensure_json_instruction
class TestEnsureJsonInstruction:
"""Tests for the ensure_json_instruction utility function."""
# -------------------------------------------------------------------
# Core behavior: append when missing, skip when present
# -------------------------------------------------------------------
def test_appends_when_json_missing_from_both_prompts(self):
"""When neither prompt contains 'json', instruction is appended to system prompt."""
system, user = ensure_json_instruction(
"Extract facts from the conversation and return them as a list.",
"Input:\nuser: Hi my name is John",
)
assert "json" in system.lower()
assert "facts" in system.lower()
def test_no_change_when_json_in_system_prompt(self):
"""When system prompt already contains 'json', no modification."""
original = "Extract facts and return in json format."
system, user = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_no_change_when_json_in_user_prompt(self):
"""When user prompt contains 'json', no modification to system prompt."""
original_system = "Extract facts from the conversation."
original_user = "Input (respond in json):\nuser: Hi"
system, user = ensure_json_instruction(original_system, original_user)
assert system == original_system
def test_user_prompt_never_modified(self):
"""The user prompt should never be modified regardless of content."""
original_user = "Input:\nuser: I like pizza"
_, user = ensure_json_instruction("Extract facts.", original_user)
assert user == original_user
# -------------------------------------------------------------------
# Case insensitivity
# -------------------------------------------------------------------
def test_case_insensitive_lowercase(self):
original = "Return results in json format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_uppercase(self):
original = "Return results in JSON format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_mixed(self):
original = "Return results in Json format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_in_user_prompt(self):
original_system = "Extract facts."
system, _ = ensure_json_instruction(original_system, "Return JSON.\nuser: Hi")
assert system == original_system
# -------------------------------------------------------------------
# Parametrized: various custom prompts
# -------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt,should_append",
[
# Prompts WITHOUT json — should append
("Extract all facts from the conversation.", True),
("You are a memory extractor. Return facts as a list.", True),
("Analyze the input and find key information.", True),
("Return data in structured format.", True),
("List the user preferences.", True),
# Prompts WITH json — should NOT append
("Extract facts and return in json format.", False),
("Return a json object with facts.", False),
("Output must be valid JSON.", False),
("Respond with a JSON array of facts.", False),
("Format: json output expected.", False),
],
)
def test_various_custom_prompts(self, prompt, should_append):
user_prompt = "Input:\nuser: Hi my name is John"
system, _ = ensure_json_instruction(prompt, user_prompt)
if should_append:
assert system != prompt, f"Expected JSON instruction to be appended for: {prompt}"
assert "json" in system.lower()
else:
assert system == prompt, f"Did not expect modification for: {prompt}"
# -------------------------------------------------------------------
# Edge cases
# -------------------------------------------------------------------
def test_empty_system_prompt(self):
"""Empty system prompt should get JSON instruction."""
system, _ = ensure_json_instruction("", "Input:\nuser: test")
assert "json" in system.lower()
def test_whitespace_only_system_prompt(self):
"""Whitespace-only prompt should get JSON instruction."""
system, _ = ensure_json_instruction(" \n ", "Input:\nuser: test")
assert "json" in system.lower()
def test_preserves_original_prompt_content(self):
"""The fix should only append, never modify the original prompt content."""
original = "Extract all user preferences and habits from the conversation."
system, _ = ensure_json_instruction(original, "Input:\nuser: I like pizza")
assert system.startswith(original)
assert len(system) > len(original)
def test_appended_instruction_mentions_facts_key(self):
"""The appended instruction should guide the model to use the 'facts' key."""
system, _ = ensure_json_instruction(
"Extract information.", "Input:\nuser: test"
)
assert "facts" in system.lower()
def test_idempotent_when_already_has_json(self):
"""Calling ensure_json_instruction twice doesn't double-append."""
system1, user1 = ensure_json_instruction(
"Extract facts.", "Input:\nuser: test"
)
system2, user2 = ensure_json_instruction(system1, user1)
assert system1 == system2
assert user1 == user2
def test_json_in_curly_braces_not_detected(self):
"""A prompt with JSON-like structure but no 'json' word should get instruction.
e.g. '{"facts": [...]}' contains the characters j,s,o,n but not the word 'json'."""
prompt = 'Return format: {"facts": [...]}'
# This contains the substring "json" inside the key name — let's check
if "json" in prompt.lower():
# If it does contain json, it won't be modified
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system == prompt
else:
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system != prompt
# -------------------------------------------------------------------
# Default prompts verification
# -------------------------------------------------------------------
def test_default_prompts_already_contain_json(self):
"""Built-in prompts already contain 'json', so ensure_json_instruction is a no-op."""
from mem0.configs.prompts import (
FACT_RETRIEVAL_PROMPT,
USER_MEMORY_EXTRACTION_PROMPT,
AGENT_MEMORY_EXTRACTION_PROMPT,
)
for name, prompt in [
("FACT_RETRIEVAL_PROMPT", FACT_RETRIEVAL_PROMPT),
("USER_MEMORY_EXTRACTION_PROMPT", USER_MEMORY_EXTRACTION_PROMPT),
("AGENT_MEMORY_EXTRACTION_PROMPT", AGENT_MEMORY_EXTRACTION_PROMPT),
]:
assert "json" in prompt.lower(), (
f"{name} should contain 'json' — "
"if this fails, the default prompts have changed"
)
# ensure_json_instruction should be a no-op for defaults
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system == prompt, f"ensure_json_instruction modified {name} unexpectedly"
# -------------------------------------------------------------------
# Integration: verify fix is wired into both sync and async paths
# -------------------------------------------------------------------
def test_fix_applied_in_sync_memory_class(self):
"""Verify the ensure_json_instruction call exists in Memory._add_to_vector_store."""
import inspect
from mem0.memory.main import Memory
source = inspect.getsource(Memory._add_to_vector_store)
assert "ensure_json_instruction" in source, (
"ensure_json_instruction not found in Memory._add_to_vector_store (sync)"
)
def test_fix_applied_in_async_memory_class(self):
"""Verify the ensure_json_instruction call exists in AsyncMemory._add_to_vector_store."""
import inspect
from mem0.memory.main import AsyncMemory
source = inspect.getsource(AsyncMemory._add_to_vector_store)
assert "ensure_json_instruction" in source, (
"ensure_json_instruction not found in AsyncMemory._add_to_vector_store (async)"
)
def test_import_exists_in_main(self):
"""Verify ensure_json_instruction is imported in main.py."""
import inspect
import mem0.memory.main as main_module
source = inspect.getsource(main_module)
assert "from mem0.memory.utils import" in source
assert "ensure_json_instruction" in source
+45 -1
View File
@@ -1,6 +1,8 @@
from unittest.mock import MagicMock, Mock, patch
import numpy as np
import pytest
from unittest.mock import Mock, patch
from mem0.memory.kuzu_memory import MemoryGraph
@@ -190,6 +192,48 @@ class TestKuzu:
assert get_node_count(kuzu_memory) == 0
assert get_edge_count(kuzu_memory) == 0
def _make_kuzu_instance():
with patch.object(MemoryGraph, "__init__", return_value=None):
instance = MemoryGraph.__new__(MemoryGraph)
instance.llm_provider = "openai"
instance.llm = MagicMock()
instance.embedding_model = MagicMock()
instance.config = MagicMock()
instance.config.graph_store.custom_prompt = None
return instance
class TestRetrieveNodesFromData:
"""Tests for _retrieve_nodes_from_data in KuzuMemoryGraph."""
def test_missing_entities_key_returns_empty(self):
"""LLM returns extract_entities tool call without 'entities' key — should not crash.
Reproduces the exact scenario from issue #4238."""
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}]
}
result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"})
assert result == {}
def test_normal_entities_extracted(self):
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "Alice", "entity_type": "person"},
{"entity": "hiking", "entity_type": "activity"},
]}}]
}
result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"})
assert result == {"alice": "person", "hiking": "activity"}
def test_none_tool_calls_returns_empty(self):
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"})
assert result == {}
def get_node_count(kuzu_memory):
results = kuzu_memory.kuzu_execute(
"""
+741 -4
View File
@@ -1,9 +1,10 @@
import logging
from unittest.mock import MagicMock
from datetime import datetime, timezone
from unittest.mock import MagicMock, Mock
import pytest
from mem0.memory.main import AsyncMemory, Memory
from mem0.memory.main import AsyncMemory, Memory, _normalize_iso_timestamp_to_utc
def _setup_mocks(mocker):
@@ -34,7 +35,7 @@ class TestAddToVectorStoreErrors:
memory = Memory()
memory.config = mocker.MagicMock()
memory.config.custom_fact_extraction_prompt = None
memory.config.custom_instructions = None
memory.config.custom_update_memory_prompt = None
memory.api_version = "v1.1"
@@ -78,6 +79,57 @@ class TestAddToVectorStoreErrors:
assert "Empty response from LLM, no memories to extract" in caplog.text
class TestAsyncUpdate:
@pytest.fixture
def mock_async_memory(self, mocker):
"""Fixture for AsyncMemory with mocker-based mocks"""
_setup_mocks(mocker)
memory = AsyncMemory()
return memory
@pytest.mark.asyncio
async def test_async_update_without_metadata(self, mock_async_memory, mocker):
"""Test async update passes None metadata by default"""
mock_async_memory.embedding_model = Mock()
mock_async_memory.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
mock_async_memory._update_memory = mocker.AsyncMock()
result = await mock_async_memory.update("test_id", "Updated memory")
mock_async_memory._update_memory.assert_called_once_with(
"test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, None
)
assert result["message"] == "Memory updated successfully!"
@pytest.mark.asyncio
async def test_async_update_with_metadata(self, mock_async_memory, mocker):
"""Test async update correctly forwards metadata"""
mock_async_memory.embedding_model = Mock()
mock_async_memory.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
mock_async_memory._update_memory = mocker.AsyncMock()
metadata = {"category": "sports", "priority": "high"}
result = await mock_async_memory.update("test_id", "Updated memory", metadata=metadata)
mock_async_memory._update_memory.assert_called_once_with(
"test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, metadata
)
assert result["message"] == "Memory updated successfully!"
@pytest.mark.asyncio
async def test_async_update_with_empty_metadata(self, mock_async_memory, mocker):
"""Test async update with empty metadata dict"""
mock_async_memory.embedding_model = Mock()
mock_async_memory.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
mock_async_memory._update_memory = mocker.AsyncMock()
await mock_async_memory.update("test_id", "Updated memory", metadata={})
mock_async_memory._update_memory.assert_called_once_with(
"test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, {}
)
@pytest.mark.asyncio
class TestAsyncAddToVectorStoreErrors:
@pytest.fixture
@@ -87,7 +139,7 @@ class TestAsyncAddToVectorStoreErrors:
memory = AsyncMemory()
memory.config = mocker.MagicMock()
memory.config.custom_fact_extraction_prompt = None
memory.config.custom_instructions = None
memory.config.custom_update_memory_prompt = None
memory.api_version = "v1.1"
@@ -127,3 +179,688 @@ class TestAsyncAddToVectorStoreErrors:
assert result == []
assert "Empty response from LLM, no memories to extract" in caplog.text
assert mock_capture_event.call_count == 1
def _build_memory_instance(mocker, memory_cls):
_setup_mocks(mocker)
mocker.patch("mem0.memory.main.SQLiteManager", mocker.MagicMock())
mocker.patch("mem0.memory.main.MEM0_TELEMETRY", False)
memory = memory_cls()
memory.config = mocker.MagicMock()
memory.config.custom_instructions = None
memory.config.custom_update_memory_prompt = None
memory.api_version = "v1.1"
memory.vector_store = mocker.MagicMock()
memory.db = mocker.MagicMock()
return memory
def _assert_utc_timestamp(timestamp: str):
parsed = datetime.fromisoformat(timestamp)
assert parsed.tzinfo == timezone.utc
assert parsed.utcoffset().total_seconds() == 0
def test_create_memory_uses_utc_timestamps(mocker):
memory = _build_memory_instance(mocker, Memory)
memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
_assert_utc_timestamp(payload["created_at"])
def test_create_memory_sets_updated_at(mocker):
memory = _build_memory_instance(mocker, Memory)
memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert "updated_at" in payload
assert payload["updated_at"] == payload["created_at"]
_assert_utc_timestamp(payload["updated_at"])
# History should also receive updated_at
history_kwargs = memory.db.add_history.call_args
assert history_kwargs.kwargs["updated_at"] == payload["updated_at"]
def test_create_memory_preserves_existing_created_at(mocker):
memory = _build_memory_instance(mocker, Memory)
custom_ts = "2023-05-06T09:19:20+00:00"
memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={"created_at": custom_ts})
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["created_at"] == custom_ts
assert payload["updated_at"] == custom_ts
def test_update_memory_uses_utc_timestamps(mocker):
memory = _build_memory_instance(mocker, Memory)
memory.vector_store.get.return_value = MagicMock(
payload={"data": "old memory", "created_at": "2026-03-17T17:00:00-07:00"}
)
memory._update_memory("memory-id", "new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
payload = memory.vector_store.update.call_args.kwargs["payload"]
assert payload["created_at"] == "2026-03-18T00:00:00+00:00"
_assert_utc_timestamp(payload["updated_at"])
@pytest.mark.asyncio
async def test_async_create_memory_uses_utc_timestamps(mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
await memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
_assert_utc_timestamp(payload["created_at"])
@pytest.mark.asyncio
async def test_async_create_memory_sets_updated_at(mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
await memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert "updated_at" in payload
assert payload["updated_at"] == payload["created_at"]
_assert_utc_timestamp(payload["updated_at"])
# History should also receive updated_at
history_kwargs = memory.db.add_history.call_args
assert history_kwargs.kwargs["updated_at"] == payload["updated_at"]
@pytest.mark.asyncio
async def test_async_create_memory_preserves_existing_created_at(mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
custom_ts = "2023-05-06T09:19:20+00:00"
await memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={"created_at": custom_ts})
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["created_at"] == custom_ts
assert payload["updated_at"] == custom_ts
@pytest.mark.asyncio
async def test_async_update_memory_uses_utc_timestamps(mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
memory.vector_store.get.return_value = MagicMock(
payload={"data": "old memory", "created_at": "2026-03-17T17:00:00-07:00"}
)
await memory._update_memory("memory-id", "new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
payload = memory.vector_store.update.call_args.kwargs["payload"]
assert payload["created_at"] == "2026-03-18T00:00:00+00:00"
_assert_utc_timestamp(payload["updated_at"])
def test_create_then_search_and_get_all_return_same_timestamps(mocker):
"""Reproduces issue #3720: created_at must be identical in search() and get_all()."""
memory = _build_memory_instance(mocker, Memory)
# Step 1: Create a memory — capture the payload stored in the vector store
memory._create_memory("Likes pizza", {"Likes pizza": [0.1, 0.2, 0.3]}, metadata={"user_id": "alice"})
stored_payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
stored_id = memory.vector_store.insert.call_args.kwargs["ids"][0]
# Verify both timestamps were stored
assert stored_payload["created_at"] is not None
assert stored_payload["updated_at"] is not None
assert stored_payload["updated_at"] == stored_payload["created_at"]
# Step 2: Simulate the vector store returning this memory for both search and get_all
mem_result = MagicMock()
mem_result.id = stored_id
mem_result.payload = stored_payload
mem_result.score = 0.95
memory.vector_store.search.return_value = [mem_result]
memory.vector_store.list.return_value = [[mem_result]]
# Step 3: Call search and get_all, compare timestamps
search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, top_k=10, threshold=None)
get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, top_k=100)
search_item = search_results[0]
get_all_item = get_all_results[0]
# The core assertion from issue #3720: created_at must be the same
assert search_item["created_at"] == get_all_item["created_at"], (
f"created_at mismatch: search={search_item['created_at']}, get_all={get_all_item['created_at']}"
)
assert search_item["updated_at"] == get_all_item["updated_at"], (
f"updated_at mismatch: search={search_item['updated_at']}, get_all={get_all_item['updated_at']}"
)
# Neither should be None
assert search_item["created_at"] is not None
assert search_item["updated_at"] is not None
assert get_all_item["created_at"] is not None
assert get_all_item["updated_at"] is not None
def test_update_preserves_created_at_and_updates_updated_at(mocker):
"""After an update, created_at must stay the same and updated_at must change."""
memory = _build_memory_instance(mocker, Memory)
# Create a memory
memory._create_memory("Likes pizza", {"Likes pizza": [0.1, 0.2, 0.3]}, metadata={"user_id": "alice"})
created_payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
created_id = memory.vector_store.insert.call_args.kwargs["ids"][0]
original_created_at = created_payload["created_at"]
# Update the memory — simulate existing memory in vector store
memory.vector_store.get.return_value = MagicMock(
id=created_id,
payload=created_payload,
)
memory._update_memory(created_id, "Loves pizza", {"Loves pizza": [0.2, 0.3, 0.4]}, metadata={})
updated_payload = memory.vector_store.update.call_args.kwargs["payload"]
# created_at must be preserved
assert updated_payload["created_at"] == original_created_at
# updated_at must be set and different from creation time (or at least present)
assert updated_payload["updated_at"] is not None
_assert_utc_timestamp(updated_payload["updated_at"])
def test_search_and_get_all_consistent_after_update(mocker):
"""After update, search and get_all must still return the same timestamps."""
memory = _build_memory_instance(mocker, Memory)
# Simulate a memory that was created then updated
updated_payload = {
"data": "Loves pizza",
"hash": "abc123",
"user_id": "alice",
"created_at": "2023-05-06T09:19:20+00:00",
"updated_at": "2026-03-23T10:00:00+00:00",
}
mem_result = MagicMock()
mem_result.id = "mem-1"
mem_result.payload = updated_payload
mem_result.score = 0.9
memory.vector_store.search.return_value = [mem_result]
memory.vector_store.list.return_value = [[mem_result]]
search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, top_k=10, threshold=None)
get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, top_k=100)
assert search_results[0]["created_at"] == get_all_results[0]["created_at"]
assert search_results[0]["updated_at"] == get_all_results[0]["updated_at"]
# created_at should be the original, not the updated time
assert search_results[0]["created_at"] == "2023-05-06T09:19:20+00:00"
assert search_results[0]["updated_at"] == "2026-03-23T10:00:00+00:00"
class TestMetadataNotMutated:
"""Tests that metadata dicts passed to memory methods are not mutated in-place (issue #2648)."""
def test_create_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, Memory)
original_metadata = {"user_id": "test_user", "category": "sports"}
metadata_copy = original_metadata.copy()
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"_create_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
def test_create_memory_stores_correct_payload(self, mocker):
memory = _build_memory_instance(mocker, Memory)
metadata = {"user_id": "test_user", "category": "sports"}
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["data"] == "test data"
assert payload["user_id"] == "test_user"
assert payload["category"] == "sports"
assert "hash" in payload
assert "created_at" in payload
def test_create_memory_with_none_metadata(self, mocker):
memory = _build_memory_instance(mocker, Memory)
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=None)
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["data"] == "test data"
assert "hash" in payload
def test_create_memory_shared_metadata_across_calls(self, mocker):
"""Verify that sharing a metadata dict between multiple _create_memory calls is safe."""
memory = _build_memory_instance(mocker, Memory)
shared_metadata = {"user_id": "test_user"}
memory._create_memory("first memory", {"first memory": [0.1, 0.2, 0.3]}, metadata=shared_metadata)
memory._create_memory("second memory", {"second memory": [0.4, 0.5, 0.6]}, metadata=shared_metadata)
assert shared_metadata == {"user_id": "test_user"}, "shared metadata was mutated across calls"
# Verify each call got the correct data
first_payload = memory.vector_store.insert.call_args_list[0].kwargs["payloads"][0]
second_payload = memory.vector_store.insert.call_args_list[1].kwargs["payloads"][0]
assert first_payload["data"] == "first memory"
assert second_payload["data"] == "second memory"
def test_create_memory_preserves_role_and_actor_id_in_history(self, mocker):
"""Verify that role and actor_id from metadata flow through to add_history after deepcopy."""
memory = _build_memory_instance(mocker, Memory)
metadata = {"user_id": "test_user", "role": "assistant", "actor_id": "bot-1"}
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
# Verify the payload stored in vector store has all fields
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["role"] == "assistant"
assert payload["actor_id"] == "bot-1"
assert payload["user_id"] == "test_user"
assert payload["data"] == "test data"
# Verify add_history received the correct role and actor_id
history_call = memory.db.add_history.call_args
assert history_call.kwargs["role"] == "assistant"
assert history_call.kwargs["actor_id"] == "bot-1"
# And the original metadata is still untouched
assert metadata == {"user_id": "test_user", "role": "assistant", "actor_id": "bot-1"}
def test_create_memory_with_nested_metadata_not_mutated(self, mocker):
"""Verify deepcopy protects nested structures in metadata."""
memory = _build_memory_instance(mocker, Memory)
metadata = {"user_id": "test_user", "tags": ["important", "urgent"], "config": {"key": "val"}}
import copy
metadata_snapshot = copy.deepcopy(metadata)
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
assert metadata == metadata_snapshot, "Nested metadata structures were mutated"
def test_update_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, Memory)
memory.vector_store.get.return_value = MagicMock(
payload={"data": "old data", "user_id": "test_user", "created_at": "2026-01-01T00:00:00+00:00"}
)
original_metadata = {"category": "updated"}
metadata_copy = original_metadata.copy()
memory._update_memory("mem-id", "new data", {"new data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"_update_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
def test_add_to_vector_store_no_infer_does_not_mutate_metadata(self, mocker):
"""Verify _add_to_vector_store with infer=False doesn't leak metadata between messages."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
original_metadata = {"user_id": "test_user"}
metadata_copy = original_metadata.copy()
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there", "name": "bot-1"},
]
result = memory._add_to_vector_store(messages, original_metadata, filters={}, infer=False)
# Metadata should not be mutated
assert original_metadata == metadata_copy, (
f"_add_to_vector_store mutated the caller's metadata: {original_metadata}"
)
# Should have created 2 memories
assert len(result) == 2
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
assert result[1]["actor_id"] == "bot-1"
# Verify each insert got distinct payloads with correct roles
insert_calls = memory.vector_store.insert.call_args_list
first_payload = insert_calls[0].kwargs["payloads"][0]
second_payload = insert_calls[1].kwargs["payloads"][0]
assert first_payload["role"] == "user"
assert "actor_id" not in first_payload # user message has no name
assert second_payload["role"] == "assistant"
assert second_payload["actor_id"] == "bot-1"
@pytest.mark.asyncio
async def test_async_create_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
original_metadata = {"user_id": "test_user", "category": "sports"}
metadata_copy = original_metadata.copy()
await memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"async _create_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
@pytest.mark.asyncio
async def test_async_create_memory_shared_metadata_across_calls(self, mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
shared_metadata = {"user_id": "test_user"}
await memory._create_memory("first memory", {"first memory": [0.1, 0.2, 0.3]}, metadata=shared_metadata)
await memory._create_memory("second memory", {"second memory": [0.4, 0.5, 0.6]}, metadata=shared_metadata)
assert shared_metadata == {"user_id": "test_user"}, "shared metadata was mutated across async calls"
@pytest.mark.asyncio
async def test_async_add_to_vector_store_no_infer_does_not_mutate_metadata(self, mocker):
"""Verify async _add_to_vector_store with infer=False doesn't leak metadata between messages."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
original_metadata = {"user_id": "test_user"}
metadata_copy = original_metadata.copy()
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there", "name": "bot-1"},
]
result = await memory._add_to_vector_store(messages, original_metadata, effective_filters={}, infer=False)
assert original_metadata == metadata_copy, (
f"async _add_to_vector_store mutated the caller's metadata: {original_metadata}"
)
assert len(result) == 2
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
@pytest.mark.asyncio
async def test_async_update_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
memory.vector_store.get.return_value = MagicMock(
payload={"data": "old data", "user_id": "test_user", "created_at": "2026-01-01T00:00:00+00:00"}
)
original_metadata = {"category": "updated"}
metadata_copy = original_metadata.copy()
await memory._update_memory("mem-id", "new data", {"new data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"async _update_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
def test_update_preserves_actor_id_when_different_actor_updates(mocker):
"""actor_id must be preserved from the original memory even when the
updating caller passes a different actor_id in metadata (issue #4490)."""
memory = _build_memory_instance(mocker, Memory)
memory.vector_store.get.return_value = MagicMock(
payload={
"data": "I am player #1",
"user_id": "team",
"actor_id": "Alice",
"created_at": "2026-01-01T00:00:00+00:00",
}
)
memory._update_memory(
"mem-id", "Player #1 is a good person",
{"Player #1 is a good person": [0.1, 0.2, 0.3]},
metadata={"user_id": "team", "actor_id": "Bob"},
)
stored = memory.vector_store.update.call_args.kwargs["payload"]
assert stored["actor_id"] == "Alice"
@pytest.mark.asyncio
async def test_async_update_preserves_actor_id_when_different_actor_updates(mocker):
"""Async variant: actor_id must be preserved from the original memory (issue #4490)."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.vector_store.get.return_value = MagicMock(
payload={
"data": "I am player #1",
"user_id": "team",
"actor_id": "Alice",
"created_at": "2026-01-01T00:00:00+00:00",
}
)
await memory._update_memory(
"mem-id", "Player #1 is a good person",
{"Player #1 is a good person": [0.1, 0.2, 0.3]},
metadata={"user_id": "team", "actor_id": "Bob"},
)
stored = memory.vector_store.update.call_args.kwargs["payload"]
assert stored["actor_id"] == "Alice"
class TestHallucinatedIdGuard:
"""Tests for temp_uuid_mapping guard against LLM-hallucinated IDs (issue #3931).
When the LLM returns an UPDATE or DELETE with an ID that doesn't exist in
temp_uuid_mapping, the code should skip gracefully instead of raising KeyError.
"""
def test_sync_update_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Sync UPDATE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
# Simulate: 2 existing memories (IDs "0" and "1"), but LLM returns UPDATE for ID "12"
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
# First LLM call: fact extraction → returns one fact
# Second LLM call: memory update actions → returns UPDATE with hallucinated ID "12"
memory.llm.generate_response.side_effect = [
'{"facts": ["User likes tea"]}',
'{"memory": [{"id": "12", "text": "User likes tea", "event": "UPDATE", "old_memory": "User likes coffee"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like tea"}],
metadata={},
filters={},
infer=True,
)
# Should not crash, should return empty (the hallucinated UPDATE was skipped)
assert result == []
assert "UPDATE skipped: LLM returned unknown id" in caplog.text
# _update_memory should NOT have been called
memory.vector_store.update.assert_not_called()
def test_sync_delete_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Sync DELETE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
memory.llm.generate_response.side_effect = [
'{"facts": ["Remove coffee preference"]}',
'{"memory": [{"id": "9", "text": "User likes coffee", "event": "DELETE"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I no longer like coffee"}],
metadata={},
filters={},
infer=True,
)
assert result == []
assert "DELETE skipped: LLM returned unknown id" in caplog.text
memory.vector_store.delete.assert_not_called()
def test_sync_valid_id_still_processes_normally(self, mocker, caplog):
"""A valid ID should still be processed — the guard must not block legitimate operations."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
memory.vector_store.get.return_value = MagicMock(
payload={"data": "User likes coffee", "created_at": "2026-01-01T00:00:00+00:00"}
)
# ID "0" is valid since there's exactly 1 existing memory
memory.llm.generate_response.side_effect = [
'{"facts": ["User likes tea now"]}',
'{"memory": [{"id": "0", "text": "User likes tea now", "event": "UPDATE", "old_memory": "User likes coffee"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like tea now"}],
metadata={},
filters={},
infer=True,
)
assert len(result) == 1
assert result[0]["event"] == "UPDATE"
assert result[0]["memory"] == "User likes tea now"
assert result[0]["id"] == "uuid-aaa"
assert "skipped" not in caplog.text
@pytest.mark.asyncio
async def test_async_update_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Async UPDATE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-bbb"
existing_mem.payload = {"data": "User works at Acme"}
memory.vector_store.search.return_value = [existing_mem]
memory.llm.generate_response.side_effect = [
'{"facts": ["User works at Globex"]}',
'{"memory": [{"id": "7", "text": "User works at Globex", "event": "UPDATE", "old_memory": "User works at Acme"}]}',
]
with caplog.at_level(logging.WARNING):
result = await memory._add_to_vector_store(
messages=[{"role": "user", "content": "I now work at Globex"}],
metadata={},
effective_filters={},
infer=True,
)
assert result == []
assert "UPDATE skipped: LLM returned unknown id" in caplog.text
@pytest.mark.asyncio
async def test_async_delete_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Async DELETE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-ccc"
existing_mem.payload = {"data": "User lives in SF"}
memory.vector_store.search.return_value = [existing_mem]
memory.llm.generate_response.side_effect = [
'{"facts": ["Remove SF reference"]}',
'{"memory": [{"id": "16", "text": "User lives in SF", "event": "DELETE"}]}',
]
with caplog.at_level(logging.WARNING):
result = await memory._add_to_vector_store(
messages=[{"role": "user", "content": "I moved away from SF"}],
metadata={},
effective_filters={},
infer=True,
)
assert result == []
assert "DELETE skipped: LLM returned unknown id" in caplog.text
def test_sync_update_with_missing_id_key_skips_gracefully(self, mocker, caplog):
"""UPDATE where the LLM omits the 'id' field entirely should be skipped."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
# LLM response has no "id" key at all
memory.llm.generate_response.side_effect = [
'{"facts": ["User likes tea"]}',
'{"memory": [{"text": "User likes tea", "event": "UPDATE", "old_memory": "User likes coffee"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like tea"}],
metadata={},
filters={},
infer=True,
)
assert result == []
assert "UPDATE skipped: LLM returned unknown id" in caplog.text
@pytest.mark.asyncio
async def test_async_valid_id_still_processes_normally(self, mocker, caplog):
"""Async path: a valid ID should process normally — no false positives from the guard."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-bbb"
existing_mem.payload = {"data": "User works at Acme"}
memory.vector_store.search.return_value = [existing_mem]
memory.vector_store.get.return_value = MagicMock(
payload={"data": "User works at Acme", "created_at": "2026-01-01T00:00:00+00:00"}
)
# ID "0" is valid since there's exactly 1 existing memory
memory.llm.generate_response.side_effect = [
'{"facts": ["User works at Globex now"]}',
'{"memory": [{"id": "0", "text": "User works at Globex now", "event": "UPDATE", "old_memory": "User works at Acme"}]}',
]
with caplog.at_level(logging.WARNING):
result = await memory._add_to_vector_store(
messages=[{"role": "user", "content": "I now work at Globex"}],
metadata={},
effective_filters={},
infer=True,
)
assert len(result) == 1
assert result[0]["event"] == "UPDATE"
assert result[0]["memory"] == "User works at Globex now"
assert result[0]["id"] == "uuid-bbb"
assert "skipped" not in caplog.text
def test_normalize_iso_timestamp_to_utc_preserves_naive_values():
assert _normalize_iso_timestamp_to_utc("2026-03-18T00:00:00") == "2026-03-18T00:00:00"
def test_normalize_iso_timestamp_to_utc_converts_pacific():
result = _normalize_iso_timestamp_to_utc("2026-03-17T17:00:00-07:00")
assert result == "2026-03-18T00:00:00+00:00"
def test_normalize_iso_timestamp_to_utc_handles_none():
assert _normalize_iso_timestamp_to_utc(None) is None
def test_normalize_iso_timestamp_to_utc_handles_empty():
assert _normalize_iso_timestamp_to_utc("") == ""
+11
View File
@@ -10,6 +10,7 @@ patch.dict("sys.modules", {
}).start()
from mem0.memory.memgraph_memory import MemoryGraph as MemgraphMemoryGraph # noqa: E402
MemoryGraph = MemgraphMemoryGraph
@@ -54,6 +55,16 @@ class TestRetrieveNodesFromData:
assert "relu" in result
assert "task" not in result
def test_missing_entities_key_returns_empty(self):
"""LLM returns extract_entities tool call without 'entities' key — should not crash.
Reproduces the exact scenario from issue #4238."""
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}]
}
result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"})
assert result == {}
def test_none_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
+57
View File
@@ -0,0 +1,57 @@
import pytest
from mem0.memory.utils import remove_spaces_from_entities, sanitize_relationship_for_cypher
class TestRemoveSpacesFromEntities:
"""
Covers behavior used by Neo4j, Memgraph (sanitize_relationship=True),
Kuzu, and Neptune (sanitize_relationship=False). All backends delegate here.
"""
@pytest.mark.parametrize(
"sanitize",
[True, False],
ids=["cypher_sanitized", "plain"],
)
def test_filters_empty_and_incomplete_dicts(self, sanitize):
mixed = [
{},
{"source": "a"},
{"source": "a", "relationship": "r"},
{"source": "x", "relationship": "rel", "destination": "y"},
]
out = remove_spaces_from_entities(mixed, sanitize_relationship=sanitize)
assert len(out) == 1
assert out[0]["source"] == "x"
assert out[0]["destination"] == "y"
@pytest.mark.parametrize("sanitize", [True, False])
def test_all_empty_returns_empty(self, sanitize):
assert remove_spaces_from_entities([{}, {}, {}], sanitize_relationship=sanitize) == []
def test_skips_non_dict_entries(self):
assert remove_spaces_from_entities([None, "not-a-dict", 123, {"source": "a", "relationship": "r", "destination": "b"}]) == [
{"source": "a", "relationship": "r", "destination": "b"}
]
def test_sanitize_true_relationship_uses_sanitizer(self):
"""Neo4j / Memgraph path: special characters mapped via sanitize_relationship_for_cypher."""
entities = [{"source": "A", "relationship": "x/y", "destination": "B"}]
out = remove_spaces_from_entities(entities, sanitize_relationship=True)
assert out[0]["relationship"] == sanitize_relationship_for_cypher("x/y".lower().replace(" ", "_"))
def test_sanitize_false_relationship_plain_only(self):
"""Kuzu / Neptune path: only lowercase and spaces to underscores."""
entities = [{"source": "A", "relationship": "Works At", "destination": "B Co"}]
out = remove_spaces_from_entities(entities, sanitize_relationship=False)
assert out[0]["relationship"] == "works_at"
assert out[0]["source"] == "a"
assert out[0]["destination"] == "b_co"
def test_sanitize_true_vs_false_slash_in_relationship(self):
"""Slash is rewritten when sanitizing (Cypher path); kept as-is for plain path."""
base = {"source": "s", "relationship": "a/b", "destination": "d"}
t = remove_spaces_from_entities([dict(base)], sanitize_relationship=True)[0]["relationship"]
f = remove_spaces_from_entities([dict(base)], sanitize_relationship=False)[0]["relationship"]
assert t == sanitize_relationship_for_cypher("a/b")
assert f == "a/b"
+26
View File
@@ -1,6 +1,32 @@
import os
from unittest.mock import Mock, patch
from mem0.memory.utils import sanitize_relationship_for_cypher
class TestSanitizeRelationshipForCypher:
"""Test that relationship names are properly sanitized for Neo4j Cypher queries."""
def test_hyphen_replaced_with_underscore(self):
"""Hyphens in relationship names cause Neo4j CypherSyntaxError and must be replaced."""
assert sanitize_relationship_for_cypher("manages_via_low-cost_models") == "manages_via_low_cost_models"
def test_multiple_hyphens(self):
assert sanitize_relationship_for_cypher("co-owns-with") == "co_owns_with"
def test_no_special_chars_unchanged(self):
assert sanitize_relationship_for_cypher("works_at") == "works_at"
def test_spaces_not_handled_here(self):
"""Spaces are replaced upstream before this function is called."""
# sanitize only handles special chars, spaces are handled by the caller
result = sanitize_relationship_for_cypher("has relationship")
assert result == "has relationship"
def test_existing_chars_still_sanitized(self):
assert "_slash_" in sanitize_relationship_for_cypher("read/write")
assert "_at_" in sanitize_relationship_for_cypher("user@company")
class TestNeo4jCypherSyntaxFix:
"""Test that Neo4j Cypher syntax fixes work correctly"""
@@ -1,8 +1,10 @@
import unittest
from unittest.mock import MagicMock, patch
import pytest
from mem0.graphs.neptune.neptunegraph import MemoryGraph
from mem0.graphs.neptune.base import NeptuneBase
from mem0.graphs.neptune.neptunegraph import MemoryGraph
class TestNeptuneMemory(unittest.TestCase):
@@ -134,7 +136,7 @@ class TestNeptuneMemory(unittest.TestCase):
mock_bm25_instance.get_top_n.return_value = reranked_results
# Call the search method
result = self.memory_graph.search("Find Alice", self.test_filters, limit=5)
result = self.memory_graph.search("Find Alice", self.test_filters, top_k=5)
# Verify the method calls
self.memory_graph._retrieve_nodes_from_data.assert_called_once_with("Find Alice", self.test_filters)
@@ -162,7 +164,7 @@ class TestNeptuneMemory(unittest.TestCase):
self.mock_graph.query.return_value = mock_query_result
# Call the get_all method
result = self.memory_graph.get_all(self.test_filters, limit=10)
result = self.memory_graph.get_all(self.test_filters, top_k=10)
# Verify the method calls
self.memory_graph._get_all_cypher.assert_called_once_with(self.test_filters, 10)
@@ -256,7 +258,7 @@ class TestNeptuneMemory(unittest.TestCase):
self.mock_graph.query.side_effect = [mock_query_result1, mock_query_result2]
# Call the _search_graph_db method
result = self.memory_graph._search_graph_db(node_list, self.test_filters, limit=10)
result = self.memory_graph._search_graph_db(node_list, self.test_filters, top_k=10)
# Verify the method calls
self.assertEqual(self.mock_embedding_model.embed.call_count, 2)
+26 -4
View File
@@ -1,8 +1,11 @@
import unittest
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from mem0.graphs.neptune.neptunedb import MemoryGraph
from mem0.graphs.neptune.base import NeptuneBase
from mem0.graphs.neptune.neptunedb import MemoryGraph
class TestNeptuneMemory(unittest.TestCase):
@@ -187,7 +190,7 @@ class TestNeptuneMemory(unittest.TestCase):
mock_bm25_instance.get_top_n.return_value = reranked_results
# Call the search method
result = self.memory_graph.search("Find Alice", self.test_filters, limit=5)
result = self.memory_graph.search("Find Alice", self.test_filters, top_k=5)
# Verify the method calls
self.memory_graph._retrieve_nodes_from_data.assert_called_once_with("Find Alice", self.test_filters)
@@ -215,7 +218,7 @@ class TestNeptuneMemory(unittest.TestCase):
self.mock_graph.query.return_value = mock_query_result
# Call the get_all method
result = self.memory_graph.get_all(self.test_filters, limit=10)
result = self.memory_graph.get_all(self.test_filters, top_k=10)
# Verify the method calls
self.memory_graph._get_all_cypher.assert_called_once_with(self.test_filters, 10)
@@ -289,6 +292,25 @@ class TestNeptuneMemory(unittest.TestCase):
# Check the result
self.assertEqual(result, mock_query_result)
def test_add_new_entities_payloads_use_utc_timestamps(self):
"""Test that Neptune vector-store payloads use UTC timestamps."""
self.memory_graph._add_new_entities_cypher(
source="alice",
source_embedding=[0.1, 0.2],
source_type="person",
destination="bob",
dest_embedding=[0.3, 0.4],
destination_type="person",
relationship="KNOWS",
user_id=self.user_id,
)
_, kwargs = self.mock_vector_store.insert.call_args
for payload in kwargs["payloads"]:
parsed = datetime.fromisoformat(payload["created_at"])
self.assertEqual(parsed.tzinfo, timezone.utc)
self.assertEqual(parsed.utcoffset().total_seconds(), 0)
def test_search_graph_db(self):
"""Test the _search_graph_db method."""
# Mock node list
@@ -309,7 +331,7 @@ class TestNeptuneMemory(unittest.TestCase):
self.mock_graph.query.side_effect = [mock_query_result1, mock_query_result2]
# Call the _search_graph_db method
result = self.memory_graph._search_graph_db(node_list, self.test_filters, limit=10)
result = self.memory_graph._search_graph_db(node_list, self.test_filters, top_k=10)
# Verify the method calls
self.assertEqual(self.mock_embedding_model.embed.call_count, 2)
+371
View File
@@ -0,0 +1,371 @@
"""Tests for _safe_deepcopy_config and _is_sensitive_field (Issue #3580).
Validates that runtime auth objects (http_auth, connection_class, etc.) are
preserved while genuinely sensitive fields (password, api_key, etc.) are
redacted during config cloning for telemetry.
"""
import threading
from dataclasses import dataclass
import pytest
from mem0.memory.main import _is_sensitive_field, _safe_deepcopy_config
# ---------------------------------------------------------------------------
# _is_sensitive_field tests
# ---------------------------------------------------------------------------
class TestRuntimeFieldsPreserved:
"""Runtime/allowlist fields must NOT be considered sensitive."""
@pytest.mark.parametrize("field", [
"http_auth",
"auth",
"connection_class",
"ssl_context",
])
def test_runtime_fields_are_not_sensitive(self, field):
assert _is_sensitive_field(field) is False
def test_runtime_fields_case_insensitive(self):
assert _is_sensitive_field("HTTP_AUTH") is False
assert _is_sensitive_field("Connection_Class") is False
class TestExactDenyList:
"""Known secret field names must be redacted."""
@pytest.mark.parametrize("field", [
"api_key",
"secret_key",
"private_key",
"access_key",
"password",
"credentials",
"credential",
"secret",
"token",
"access_token",
"refresh_token",
"auth_token",
"session_token",
"client_secret",
"auth_client_secret",
"azure_client_secret",
"service_account_json",
"aws_session_token",
])
def test_exact_sensitive_fields(self, field):
assert _is_sensitive_field(field) is True
def test_exact_fields_case_insensitive(self):
assert _is_sensitive_field("API_KEY") is True
assert _is_sensitive_field("Password") is True
class TestSuffixDenyList:
"""Fields ending with sensitive suffixes must be redacted."""
@pytest.mark.parametrize("field", [
"db_password",
"user_password",
"redis_password",
"app_secret",
"client_secret",
"oauth_token",
"bearer_token",
"aws_credential",
"gcp_credentials",
])
def test_suffix_matches(self, field):
assert _is_sensitive_field(field) is True
class TestNonSensitiveFields:
"""Common config fields that must NOT be redacted."""
@pytest.mark.parametrize("field", [
"host",
"port",
"collection_name",
"embedding_model_dims",
"use_ssl",
"verify_certs",
"index_name",
"dimension",
"metric",
"path",
"url",
"timeout",
"pool_maxsize",
])
def test_common_config_fields(self, field):
assert _is_sensitive_field(field) is False
class TestOverMatchingPrevention:
"""Fields that previously matched due to broad substring matching
but should NOT be redacted."""
@pytest.mark.parametrize("field", [
"primary_key", # contains "key" but is a DB concept
"partition_key", # contains "key" but is a DB concept
"shard_key", # contains "key" but is a DB concept
"token_type", # contains "token" but is metadata
"token_count", # contains "token" but is a count
"tokenizer", # contains "token" but is a tool name
"key_space", # contains "key" but is a namespace
"keyboard", # contains "key" but is unrelated
"monkey", # contains "key" but is unrelated
"authenticate", # contains "auth" but is a verb
"authorization_url", # contains "auth" but is a URL
"credentials_path", # contains "credential" but is a file path
"secret_agent_name", # contains "secret" but is not a suffix match
])
def test_no_over_matching(self, field):
assert _is_sensitive_field(field) is False
class TestEdgeCases:
def test_empty_string(self):
assert _is_sensitive_field("") is False
def test_whitespace_stripped(self):
assert _is_sensitive_field(" api_key ") is True
assert _is_sensitive_field(" http_auth ") is False
class TestRealWorldFieldCoverage:
"""Verify behavior for actual field names from mem0 vector store configs."""
@pytest.mark.parametrize("field,expected", [
# OpenSearch
("password", True),
("api_key", True),
("http_auth", False),
("connection_class", False),
("host", False),
("port", False),
("verify_certs", False),
("use_ssl", False),
("pool_maxsize", False),
# Weaviate
("auth_client_secret", True),
# Databricks
("access_token", True),
("client_secret", True),
("azure_client_secret", True),
# Upstash / Milvus
("token", True),
# Vertex AI
("service_account_json", True),
("credentials_path", False),
# AWS
("aws_session_token", True),
# Azure MySQL
("use_azure_credential", False),
# General non-sensitive
("collection_name", False),
("embedding_model_dims", False),
("user", False),
("path", False),
("url", False),
("dimension", False),
("metric_type", False),
("batch_size", False),
("index_type", False),
])
def test_field_sensitivity(self, field, expected):
assert _is_sensitive_field(field) is expected
# ---------------------------------------------------------------------------
# _safe_deepcopy_config integration tests
# ---------------------------------------------------------------------------
class MockNonCopyableAuth:
"""Simulates AWSV4SignerAuth which cannot be deep-copied due to thread locks."""
def __init__(self):
self._lock = threading.Lock()
self.region = "us-east-1"
def __deepcopy__(self, memo):
raise TypeError("cannot pickle '_thread.lock' object")
class MockConnectionClass:
def __init__(self):
self._state = {"connected": False}
def __deepcopy__(self, memo):
raise TypeError("cannot pickle connection state")
class PlainConfig:
"""Config object using plain attributes (not Pydantic)."""
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class TestSafeDeepcopyClonesNormally:
"""When deepcopy succeeds, config is returned as-is (no sanitization)."""
def test_deepcopy_success_returns_clone(self):
config = PlainConfig(host="localhost", port=9200, password="super_secret")
result = _safe_deepcopy_config(config)
assert result is not config
assert result.host == "localhost"
assert result.port == 9200
# deepcopy success path does not sanitize
assert result.password == "super_secret"
class TestSafeDeepcopyCopiesWithAuth:
"""When deepcopy fails (auth objects), fallback preserves auth and redacts secrets."""
def test_preserves_http_auth_and_connection_class(self):
auth = MockNonCopyableAuth()
conn = MockConnectionClass()
config = PlainConfig(
host="localhost",
port=9200,
http_auth=auth,
connection_class=conn,
api_key="secret123",
password="hunter2",
collection_name="test",
)
result = _safe_deepcopy_config(config)
# Runtime objects preserved (not None)
assert result.http_auth is not None
assert result.connection_class is not None
# Sensitive fields redacted
assert result.api_key is None
assert result.password is None
# Normal fields preserved
assert result.host == "localhost"
assert result.port == 9200
assert result.collection_name == "test"
def test_preserves_auth_field(self):
auth = MockNonCopyableAuth()
config = PlainConfig(
host="localhost",
auth=auth,
credentials={"key": "val"},
)
result = _safe_deepcopy_config(config)
assert result.auth is not None
assert result.credentials is None
class TestSafeDeepcopyWithPydantic:
"""Test fallback path with Pydantic-like model_dump objects."""
def test_pydantic_like_config(self):
class PydanticLikeConfig:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
def model_dump(self, mode=None):
return {k: v for k, v in self.__dict__.items()
if not k.startswith("_")}
def __deepcopy__(self, memo):
raise TypeError("cannot deepcopy")
config = PydanticLikeConfig(
host="localhost",
api_key="secret",
http_auth="signer_obj",
)
result = _safe_deepcopy_config(config)
assert result.host == "localhost"
assert result.api_key is None
assert result.http_auth is not None
class TestSafeDeepcopyWithRealPydanticModel:
"""Test with real Pydantic BaseModel matching the OpenSearch config pattern.
This validates the model_dump() path (without mode='json') preserves
actual auth objects rather than losing them to JSON serialization.
"""
def test_real_pydantic_model_preserves_auth_objects(self):
from pydantic import BaseModel, Field
from typing import Optional
class OpenSearchLikeConfig(BaseModel):
host: str = "localhost"
port: int = 9200
collection_name: str = "test"
password: Optional[str] = None
api_key: Optional[str] = None
http_auth: Optional[object] = Field(None)
connection_class: Optional[object] = Field(None)
auth = MockNonCopyableAuth()
conn = MockConnectionClass()
config = OpenSearchLikeConfig(
host="myhost",
password="hunter2",
api_key="sk-secret",
http_auth=auth,
connection_class=conn,
)
result = _safe_deepcopy_config(config)
# Auth objects must be the actual objects, not string representations
assert result.http_auth is auth
assert result.connection_class is conn
# Sensitive fields must be redacted
assert result.password is None
assert result.api_key is None
# Normal fields preserved
assert result.host == "myhost"
assert result.port == 9200
class TestSafeDeepcopyWithDataclass:
"""Test fallback path with dataclasses."""
def test_dataclass_config(self):
@dataclass
class DCConfig:
host: str = "localhost"
api_key: str = None
db_password: str = None
http_auth: object = None
def __deepcopy__(self, memo):
raise TypeError("cannot deepcopy")
config = DCConfig(
host="myhost",
api_key="secret",
db_password="pass123",
http_auth="auth_obj",
)
result = _safe_deepcopy_config(config)
assert result.host == "myhost"
assert result.api_key is None
assert result.db_password is None
assert result.http_auth is not None
+11
View File
@@ -0,0 +1,11 @@
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def mock_llm():
with patch("mem0.reranker.llm_reranker.LlmFactory") as mock_factory:
mock_llm_instance = MagicMock()
mock_factory.create.return_value = mock_llm_instance
yield mock_factory, mock_llm_instance
@@ -0,0 +1,63 @@
from mem0.configs.rerankers.base import BaseRerankerConfig
from mem0.configs.rerankers.llm import LLMRerankerConfig
from mem0.reranker.llm_reranker import LLMReranker
class TestLLMRerankerConfig:
def test_default_config(self):
config = LLMRerankerConfig()
assert config.model == "gpt-4o-mini"
assert config.provider == "openai"
assert config.temperature == 0.0
assert config.max_tokens == 100
assert config.llm is None
assert config.scoring_prompt is None
assert config.top_k is None
def test_nested_llm_field_accepted(self):
config = LLMRerankerConfig(
llm={"provider": "ollama", "config": {"ollama_base_url": "http://localhost:11434"}}
)
assert config.llm["provider"] == "ollama"
assert config.llm["config"]["ollama_base_url"] == "http://localhost:11434"
class TestLLMRerankerInit:
def test_init_with_dict_config(self, mock_llm):
mock_factory, _ = mock_llm
reranker = LLMReranker({"provider": "openai", "model": "gpt-4o", "api_key": "sk-test"})
assert reranker.config.provider == "openai"
assert reranker.config.model == "gpt-4o"
mock_factory.create.assert_called_once_with(
"openai",
{"model": "gpt-4o", "temperature": 0.0, "max_tokens": 100, "api_key": "sk-test"},
)
def test_init_with_llm_reranker_config(self, mock_llm):
mock_factory, _ = mock_llm
config = LLMRerankerConfig(provider="anthropic", model="claude-3-haiku", api_key="sk-ant")
reranker = LLMReranker(config)
assert reranker.config.provider == "anthropic"
mock_factory.create.assert_called_once_with(
"anthropic",
{"model": "claude-3-haiku", "temperature": 0.0, "max_tokens": 100, "api_key": "sk-ant"},
)
def test_init_converts_base_reranker_config(self, mock_llm):
mock_factory, _ = mock_llm
base_config = BaseRerankerConfig(provider="openai", model="gpt-4o-mini")
reranker = LLMReranker(base_config)
assert isinstance(reranker.config, LLMRerankerConfig)
assert reranker.config.temperature == 0.0
assert reranker.config.max_tokens == 100
def test_init_without_api_key(self, mock_llm):
mock_factory, _ = mock_llm
LLMReranker({"provider": "openai", "model": "gpt-4o-mini"})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert "api_key" not in llm_config
@@ -0,0 +1,154 @@
from mem0.reranker.llm_reranker import LLMReranker
class TestNestedLLMConfig:
def test_nested_llm_overrides_provider(self, mock_llm):
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"llm": {
"provider": "ollama",
"config": {"model": "llama3", "ollama_base_url": "http://localhost:11434"},
},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "ollama"
def test_nested_llm_passes_provider_specific_config(self, mock_llm):
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"llm": {
"provider": "ollama",
"config": {
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["ollama_base_url"] == "http://localhost:11434"
assert llm_config["model"] == "llama3"
def test_nested_llm_inherits_top_level_defaults(self, mock_llm):
"""Nested config should inherit temperature/max_tokens from top-level if not overridden."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"temperature": 0.0,
"max_tokens": 100,
"llm": {
"provider": "ollama",
"config": {"model": "llama3"},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["temperature"] == 0.0
assert llm_config["max_tokens"] == 100
def test_nested_llm_config_values_take_precedence(self, mock_llm):
"""Values explicitly set in nested config should not be overridden by top-level defaults."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.0,
"max_tokens": 100,
"llm": {
"provider": "ollama",
"config": {
"model": "custom-model",
"temperature": 0.5,
"max_tokens": 200,
},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["model"] == "custom-model"
assert llm_config["temperature"] == 0.5
assert llm_config["max_tokens"] == 200
def test_nested_llm_falls_back_to_top_level_provider(self, mock_llm):
"""If nested llm dict has no 'provider', use top-level provider."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "anthropic",
"model": "claude-3-haiku",
"llm": {
"config": {"model": "claude-3-sonnet"},
},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "anthropic"
assert call_args[0][1]["model"] == "claude-3-sonnet"
def test_nested_llm_with_empty_config(self, mock_llm):
"""Nested llm with no config dict should still work, using top-level defaults."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"llm": {"provider": "ollama"},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "ollama"
llm_config = call_args[0][1]
assert llm_config["model"] == "gpt-4o-mini"
assert llm_config["temperature"] == 0.0
assert llm_config["max_tokens"] == 100
def test_nested_llm_with_none_config(self, mock_llm):
"""Nested llm with config: None should still work, using top-level defaults."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"llm": {"provider": "ollama", "config": None},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "ollama"
llm_config = call_args[0][1]
assert llm_config["model"] == "gpt-4o-mini"
def test_nested_llm_inherits_top_level_api_key(self, mock_llm):
"""Top-level api_key should be inherited by nested config if not already set."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"api_key": "sk-top-level",
"llm": {
"provider": "openai",
"config": {"model": "gpt-4o"},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["api_key"] == "sk-top-level"
def test_nested_llm_config_api_key_not_overridden(self, mock_llm):
"""If nested config already has api_key, top-level api_key should not override it."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"api_key": "sk-top-level",
"llm": {
"provider": "openai",
"config": {"model": "gpt-4o", "api_key": "sk-nested"},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["api_key"] == "sk-nested"
+125
View File
@@ -0,0 +1,125 @@
import pytest
from mem0.reranker.llm_reranker import LLMReranker
class TestExtractScore:
@pytest.fixture
def reranker(self, mock_llm):
return LLMReranker({"provider": "openai"})
@pytest.mark.parametrize(
"text,expected",
[
("0.85", 0.85),
("0.0", 0.0),
("1.0", 1.0),
("The score is 0.72.", 0.72),
("Score: 0.9 out of 1.0", 0.9),
],
)
def test_valid_scores(self, reranker, text, expected):
assert reranker._extract_score(text) == expected
def test_no_score_returns_fallback(self, reranker):
assert reranker._extract_score("no numbers here") == 0.5
def test_clamps_to_1(self, reranker):
assert reranker._extract_score("1.0") == 1.0
class TestRerank:
def test_empty_documents(self, mock_llm):
reranker = LLMReranker({"provider": "openai"})
result = reranker.rerank("query", [])
assert result == []
def test_documents_sorted_by_score_descending(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = ["0.3", "0.9", "0.6"]
reranker = LLMReranker({"provider": "openai"})
docs = [
{"memory": "low relevance"},
{"memory": "high relevance"},
{"memory": "mid relevance"},
]
result = reranker.rerank("test query", docs)
assert len(result) == 3
assert result[0]["rerank_score"] == 0.9
assert result[1]["rerank_score"] == 0.6
assert result[2]["rerank_score"] == 0.3
def test_top_k_limits_results(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = ["0.9", "0.5", "0.1"]
reranker = LLMReranker({"provider": "openai"})
docs = [{"memory": f"doc{i}"} for i in range(3)]
result = reranker.rerank("query", docs, top_k=2)
assert len(result) == 2
def test_config_top_k_used_when_arg_not_provided(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = ["0.9", "0.5", "0.1"]
reranker = LLMReranker({"provider": "openai", "top_k": 1})
docs = [{"memory": f"doc{i}"} for i in range(3)]
result = reranker.rerank("query", docs)
assert len(result) == 1
def test_text_field_extraction(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.8"
reranker = LLMReranker({"provider": "openai"})
reranker.rerank("query", [{"text": "some text"}])
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
assert "some text" in prompt_sent
def test_content_field_extraction(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.8"
reranker = LLMReranker({"provider": "openai"})
reranker.rerank("query", [{"content": "some content"}])
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
assert "some content" in prompt_sent
def test_fallback_score_on_llm_error(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = RuntimeError("API error")
reranker = LLMReranker({"provider": "openai"})
result = reranker.rerank("query", [{"memory": "doc"}])
assert len(result) == 1
assert result[0]["rerank_score"] == 0.5
def test_custom_scoring_prompt(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.7"
custom_prompt = "Rate this: query={query} doc={document}"
reranker = LLMReranker({"provider": "openai", "scoring_prompt": custom_prompt})
reranker.rerank("my query", [{"memory": "my doc"}])
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
assert prompt_sent == "Rate this: query=my query doc=my doc"
def test_original_doc_not_mutated(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.8"
reranker = LLMReranker({"provider": "openai"})
original_doc = {"memory": "test", "id": "123"}
result = reranker.rerank("query", [original_doc])
assert "rerank_score" not in original_doc
assert "rerank_score" in result[0]
+232
View File
@@ -0,0 +1,232 @@
"""Tests for chatty LLM JSON parsing robustness.
Validates that extract_json and the full fallback chain correctly handle
responses from local LLMs (LM Studio, Ollama) that wrap JSON in
conversational text, markdown code blocks, or both.
"""
import json
import pytest
from mem0.memory.utils import extract_json, remove_code_blocks
# --- Test extract_json ---
class TestExtractJson:
"""Tests for extract_json utility."""
def test_pure_json(self):
text = '{"memory": [{"id": "0", "text": "likes basketball", "event": "ADD"}]}'
result = extract_json(text)
parsed = json.loads(result)
assert parsed["memory"][0]["text"] == "likes basketball"
def test_json_in_markdown_code_block(self):
text = '```json\n{"memory": [{"id": "0", "text": "likes basketball", "event": "ADD"}]}\n```'
result = extract_json(text)
parsed = json.loads(result)
assert parsed["memory"][0]["text"] == "likes basketball"
def test_json_in_plain_code_block(self):
text = '```\n{"memory": [{"id": "0", "text": "likes basketball", "event": "ADD"}]}\n```'
result = extract_json(text)
parsed = json.loads(result)
assert parsed["memory"][0]["text"] == "likes basketball"
def test_chatty_with_markdown(self):
"""extract_json can handle embedded code blocks via re.search."""
text = """Here is the extracted memory:
```json
{"memory": [{"id": "0", "text": "likes basketball", "event": "ADD"}]}
```
I hope this helps!"""
result = extract_json(text)
parsed = json.loads(result)
assert parsed["memory"][0]["text"] == "likes basketball"
def test_chatty_without_markdown(self):
"""extract_json finds JSON by {/} boundaries when no code block is present."""
text = """Here is the memory update you requested:
{"memory": [{"id": "0", "text": "likes gaming", "event": "ADD"}]}
That's the result."""
result = extract_json(text)
parsed = json.loads(result)
assert parsed["memory"][0]["text"] == "likes gaming"
def test_chatty_multiline_json_without_markdown(self):
"""extract_json finds multi-line JSON by {/} boundaries."""
text = """Here is the memory update:
{
"memory": [
{
"id": "0",
"text": "User likes gaming",
"event": "ADD"
}
]
}
"""
result = extract_json(text)
parsed = json.loads(result)
assert parsed["memory"][0]["text"] == "User likes gaming"
def test_no_json_at_all(self):
"""No JSON in the text — should return as-is."""
text = "I don't have any memory updates."
result = extract_json(text)
assert result == text
def test_whitespace_padding(self):
text = ' \n {"memory": []} \n '
result = extract_json(text)
parsed = json.loads(result)
assert parsed == {"memory": []}
def test_nested_json_objects(self):
"""JSON with nested objects should find outermost braces."""
text = 'Sure! {"memory": [{"id": "0", "text": "test", "event": "ADD"}]} Done.'
result = extract_json(text)
parsed = json.loads(result)
assert parsed["memory"][0]["id"] == "0"
# --- Test remove_code_blocks ---
class TestRemoveCodeBlocks:
"""Tests for remove_code_blocks — verify it does NOT handle chatty text."""
def test_clean_code_block(self):
text = '```json\n{"memory": []}\n```'
result = remove_code_blocks(text)
parsed = json.loads(result)
assert parsed == {"memory": []}
def test_no_code_block(self):
"""Without code blocks, returns content as-is (may not be valid JSON)."""
text = 'Here is the result: {"memory": []}'
result = remove_code_blocks(text)
# This should NOT be parseable as JSON because it has leading text
with pytest.raises(json.JSONDecodeError):
json.loads(result)
def test_think_tags_removed_inside_code_block(self):
"""Think tags inside a code block response are removed."""
text = '```json\n<think>reasoning here</think>\n{"memory": []}\n```'
result = remove_code_blocks(text)
parsed = json.loads(result)
assert parsed == {"memory": []}
def test_think_tags_before_code_block_not_handled(self):
"""Think tags before code block cause regex to miss — fallback needed.
This is a known limitation of remove_code_blocks: it matches the code
block regex first, and only strips think tags from the result. When
think tags come before the code block, the regex fails entirely.
The fallback chain (extract_json) handles this case.
"""
text = '<think>reasoning here</think>\n```json\n{"memory": []}\n```'
result = remove_code_blocks(text)
with pytest.raises(json.JSONDecodeError):
json.loads(result)
# --- Test the full fallback chain (remove_code_blocks -> extract_json) ---
class TestFallbackChain:
"""Tests the actual fallback pattern used in _add_to_vector_store:
try json.loads(remove_code_blocks(response))
except JSONDecodeError: json.loads(extract_json(response))
"""
def _parse_with_fallback(self, response):
"""Mimics the parsing logic in _add_to_vector_store."""
try:
return json.loads(remove_code_blocks(response), strict=False)
except json.JSONDecodeError:
extracted = extract_json(response)
return json.loads(extracted, strict=False)
def test_clean_json(self):
response = '{"memory": [{"id": "0", "text": "Name is Alex", "event": "ADD"}]}'
result = self._parse_with_fallback(response)
assert result["memory"][0]["text"] == "Name is Alex"
def test_markdown_wrapped(self):
response = '```json\n{"memory": [{"id": "0", "text": "Name is Alex", "event": "ADD"}]}\n```'
result = self._parse_with_fallback(response)
assert result["memory"][0]["text"] == "Name is Alex"
def test_chatty_markdown(self):
"""Issue #3788: LLM wraps in conversational text + markdown."""
response = """Here is the extracted memory:
```json
{
"memory": [
{"id": "0", "text": "Name is Alex", "event": "ADD"},
{"id": "1", "text": "Love basketball", "event": "ADD"},
{"id": "2", "text": "Love gaming", "event": "ADD"}
]
}
```
I hope this helps!"""
result = self._parse_with_fallback(response)
assert len(result["memory"]) == 3
assert result["memory"][0]["text"] == "Name is Alex"
assert result["memory"][1]["text"] == "Love basketball"
assert result["memory"][2]["text"] == "Love gaming"
def test_chatty_no_markdown(self):
"""Issue #3788: LLM wraps in conversational text without markdown."""
response = """Here is the memory update you requested:
{
"memory": [
{"id": "0", "text": "Name is Alex", "event": "ADD"},
{"id": "1", "text": "Love basketball", "event": "ADD"}
]
}"""
result = self._parse_with_fallback(response)
assert len(result["memory"]) == 2
def test_think_tags_with_json(self):
"""Reasoning model with <think> tags."""
response = '<think>Let me process this...</think>\n```json\n{"memory": [{"id": "0", "text": "test", "event": "ADD"}]}\n```'
result = self._parse_with_fallback(response)
assert result["memory"][0]["text"] == "test"
def test_lmstudio_style_response(self):
"""Mimics the actual LM Studio response format from the issue."""
response = """{
"memory": [
{
"id": "0",
"text": "Name is Alex",
"event": "ADD"
},
{
"id": "1",
"text": "Love basketball",
"event": "ADD"
},
{
"id": "2",
"text": "Love gaming",
"event": "ADD"
}
]
}"""
result = self._parse_with_fallback(response)
assert len(result["memory"]) == 3
def test_completely_invalid_response(self):
"""If both attempts fail, the outer except in main.py catches it."""
response = "I don't understand the question"
with pytest.raises(json.JSONDecodeError):
self._parse_with_fallback(response)
def test_facts_extraction_fallback(self):
"""Also verify the pattern works for facts extraction responses."""
response = 'Sure! Here are the facts:\n{"facts": ["Name is Alex", "Loves basketball"]}\nHope that helps!'
result = self._parse_with_fallback(response)
assert result["facts"] == ["Name is Alex", "Loves basketball"]
+821
View File
@@ -0,0 +1,821 @@
"""
End-to-end tests for graph cleanup on memory deletion against real
Neo4j, Memgraph, and Apache AGE instances running in Docker.
Requires:
docker run -d --name mem0-neo4j-test -p 7687:7687 -e NEO4J_AUTH=neo4j/testpassword neo4j:5.23
docker run -d --name mem0-memgraph-test -p 7688:7687 memgraph/memgraph:latest
docker run -d --name mem0-age-test -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=testpassword -e POSTGRES_DB=testdb apache/age:latest
Tests are skipped automatically if the databases or required Python
packages are not available.
"""
import hashlib
import sys
import warnings
from unittest.mock import MagicMock
import pytest
warnings.filterwarnings("ignore")
EMBEDDING_DIMS = 64
# ---------------------------------------------------------------------------
# Deterministic embedding helper (shared across backends)
# ---------------------------------------------------------------------------
def _make_deterministic_embedder():
cache = {}
counter = [0]
def embed(text, *args, **kwargs):
t = text.lower().strip()
if t not in cache:
vec = [0.0] * EMBEDDING_DIMS
idx = counter[0] % EMBEDDING_DIMS
vec[idx] = 1.0
h = hashlib.sha256(t.encode()).digest()
for i in range(EMBEDDING_DIMS):
vec[i] += float(h[i % len(h)]) / 25500.0
norm = sum(v * v for v in vec) ** 0.5
cache[t] = [v / norm for v in vec]
counter[0] += 1
return cache[t]
mock = MagicMock()
mock.embed.side_effect = embed
mock.config.embedding_dims = EMBEDDING_DIMS
return mock
def _make_mock_llm(entities, relations):
"""Create an LLM mock that returns specific entities and relations."""
mock = MagicMock()
def generate_response(messages, tools):
tool_names = []
for t in tools:
if isinstance(t, dict):
fn = t.get("function", t)
tool_names.append(fn.get("name", ""))
else:
tool_names.append(getattr(t, "name", str(t)))
if any("extract_entities" in n for n in tool_names):
return {
"tool_calls": [
{"name": "extract_entities", "arguments": {"entities": entities}}
]
}
elif any("establish" in n or "relation" in n for n in tool_names):
return {
"tool_calls": [
{"name": "establish_nodes_relations", "arguments": {"entities": relations}}
]
}
elif any("delete" in n for n in tool_names):
return {"tool_calls": []}
return {"tool_calls": []}
mock.generate_response.side_effect = generate_response
return mock
# ===========================================================================
# NEO4J
# ===========================================================================
def _port_open(host, port, timeout=1):
"""Quick TCP check — avoids slow driver-level timeouts."""
import socket
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _neo4j_available():
if not _port_open("localhost", 7687):
return False
try:
from langchain_neo4j import Neo4jGraph
g = Neo4jGraph(
url="bolt://localhost:7687",
username="neo4j",
password="testpassword",
refresh_schema=False,
driver_config={"notifications_min_severity": "OFF"},
)
g.query("RETURN 1")
return True
except Exception:
return False
requires_neo4j = pytest.mark.skipif(not _neo4j_available(), reason="Neo4j not available")
@pytest.fixture
def neo4j_graph():
"""Create a Neo4j-backed MemoryGraph with mocked LLM/embedder."""
from langchain_neo4j import Neo4jGraph
from mem0.memory.graph_memory import MemoryGraph
mg = MemoryGraph.__new__(MemoryGraph)
mg.graph = Neo4jGraph(
url="bolt://localhost:7687",
username="neo4j",
password="testpassword",
refresh_schema=False,
driver_config={"notifications_min_severity": "OFF"},
)
mg.graph.query("MATCH (n) DETACH DELETE n")
mg.node_label = ":`__Entity__`"
mg.llm_provider = "openai"
mg.user_id = None
mg.threshold = 0.99
mg.embedding_model = _make_deterministic_embedder()
mg.llm = MagicMock()
mg.config = MagicMock()
mg.config.graph_store.custom_prompt = None
mg.config.graph_store.config.base_label = True
yield mg
mg.graph.query("MATCH (n) DETACH DELETE n")
@requires_neo4j
class TestNeo4jDeleteE2E:
def _node_count(self, mg):
return mg.graph.query("MATCH (n) RETURN count(n) AS cnt")[0]["cnt"]
def _valid_edge_count(self, mg):
return mg.graph.query(
"MATCH ()-[r]->() WHERE r.valid IS NULL OR r.valid = true RETURN count(r) AS cnt"
)[0]["cnt"]
def _invalid_edge_count(self, mg):
return mg.graph.query(
"MATCH ()-[r]->() WHERE r.valid = false RETURN count(r) AS cnt"
)[0]["cnt"]
def test_add_creates_graph_data(self, neo4j_graph):
mg = neo4j_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._node_count(mg) == 2
assert self._valid_edge_count(mg) == 1
def test_delete_soft_deletes_relationships(self, neo4j_graph):
"""Neo4j delete() should set r.valid=false (soft-delete), not hard-delete."""
mg = neo4j_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 1
assert self._invalid_edge_count(mg) == 0
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 0
assert self._invalid_edge_count(mg) == 1 # soft-deleted, not removed
assert self._node_count(mg) == 2 # nodes preserved
def test_delete_preserves_other_relationships(self, neo4j_graph):
mg = neo4j_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}],
[{"source": "Alice", "destination": "Charlie", "relationship": "knows"}],
)
mg.add("Alice knows Charlie", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 2
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 1
assert self._invalid_edge_count(mg) == 1
def test_delete_user_isolation(self, neo4j_graph):
mg = neo4j_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
mg.add("Alice likes Bob", {"user_id": "u2"})
assert self._valid_edge_count(mg) == 2
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 1
def test_delete_all_hard_deletes(self, neo4j_graph):
mg = neo4j_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._node_count(mg) == 2
mg.delete_all({"user_id": "u1"})
assert self._node_count(mg) == 0
def test_add_delete_add_cycle(self, neo4j_graph):
mg = neo4j_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 1
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 0
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._valid_edge_count(mg) == 1
# ===========================================================================
# MEMGRAPH
# ===========================================================================
def _memgraph_available():
if not _port_open("localhost", 7688):
return False
try:
from langchain_memgraph.graphs.memgraph import Memgraph
g = Memgraph("bolt://localhost:7688", "memgraph", "memgraph")
g.query("RETURN 1")
return True
except Exception:
return False
requires_memgraph = pytest.mark.skipif(
not _memgraph_available(), reason="Memgraph not available"
)
@pytest.fixture
def memgraph_graph():
"""Create a Memgraph-backed MemoryGraph with mocked LLM/embedder."""
from langchain_memgraph.graphs.memgraph import Memgraph
from mem0.memory.memgraph_memory import MemoryGraph
mg = MemoryGraph.__new__(MemoryGraph)
mg.graph = Memgraph("bolt://localhost:7688", "memgraph", "memgraph")
mg.graph.query("MATCH (n) DETACH DELETE n")
try:
mg.graph.query("DROP VECTOR INDEX memzero;")
except Exception:
pass
mg.graph.query(
f"CREATE VECTOR INDEX memzero ON :Entity(embedding) "
f"WITH CONFIG {{'dimension': {EMBEDDING_DIMS}, 'capacity': 1000, 'metric': 'cos'}};"
)
try:
mg.graph.query("CREATE INDEX ON :Entity(user_id);")
except Exception:
pass
try:
mg.graph.query("CREATE INDEX ON :Entity;")
except Exception:
pass
mg.llm_provider = "openai"
mg.user_id = None
mg.threshold = 0.99
mg.embedding_model = _make_deterministic_embedder()
mg.llm = MagicMock()
mg.config = MagicMock()
mg.config.graph_store.custom_prompt = None
mg.config.embedder.config = {"embedding_dims": EMBEDDING_DIMS}
yield mg
mg.graph.query("MATCH (n) DETACH DELETE n")
@requires_memgraph
class TestMemgraphDeleteE2E:
def _node_count(self, mg):
return mg.graph.query("MATCH (n:Entity) RETURN count(n) AS cnt")[0]["cnt"]
def _edge_count(self, mg):
return mg.graph.query("MATCH ()-[r]->() RETURN count(r) AS cnt")[0]["cnt"]
def test_add_creates_graph_data(self, memgraph_graph):
mg = memgraph_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._node_count(mg) == 2
assert self._edge_count(mg) == 1
def test_delete_hard_deletes_relationships(self, memgraph_graph):
"""Memgraph delete() should hard-delete the relationship (DELETE r)."""
mg = memgraph_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._edge_count(mg) == 1
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._edge_count(mg) == 0
assert self._node_count(mg) == 2
def test_delete_preserves_other_relationships(self, memgraph_graph):
mg = memgraph_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}],
[{"source": "Alice", "destination": "Charlie", "relationship": "knows"}],
)
mg.add("Alice knows Charlie", {"user_id": "u1"})
assert self._edge_count(mg) == 2
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._edge_count(mg) == 1
def test_delete_user_isolation(self, memgraph_graph):
mg = memgraph_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
mg.add("Alice likes Bob", {"user_id": "u2"})
assert self._edge_count(mg) == 2
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._edge_count(mg) == 1
def test_delete_all_hard_deletes(self, memgraph_graph):
mg = memgraph_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._node_count(mg) == 2
mg.delete_all({"user_id": "u1"})
assert self._node_count(mg) == 0
assert self._edge_count(mg) == 0
def test_add_delete_add_cycle(self, memgraph_graph):
mg = memgraph_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._edge_count(mg) == 1
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert self._edge_count(mg) == 0
mg.add("Alice likes Bob", {"user_id": "u1"})
assert self._edge_count(mg) == 1
# ===========================================================================
# APACHE AGE
# ===========================================================================
def _age_available():
if not _port_open("localhost", 5432):
return False
try:
import age
ag = age.connect(
host="localhost",
port=5432,
dbname="testdb",
user="postgres",
password="testpassword",
)
with ag.connection.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS age;")
cur.execute("SET search_path = ag_catalog, '$user', public;")
ag.connection.commit()
ag.close()
return True
except Exception:
return False
requires_age = pytest.mark.skipif(not _age_available(), reason="Apache AGE not available")
@pytest.fixture
def age_graph():
"""Create an Apache AGE-backed MemoryGraph with mocked LLM/embedder."""
import age
from mem0.memory.apache_age_memory import MemoryGraph
graph_name = "mem0_test_delete"
ag = age.connect(
graph=graph_name,
host="localhost",
port=5432,
dbname="testdb",
user="postgres",
password="testpassword",
)
with ag.connection.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS age;")
cur.execute("SET search_path = ag_catalog, '$user', public;")
ag.connection.commit()
age.setUpAge(ag.connection, graph_name)
ag.connection.commit()
mg = MemoryGraph.__new__(MemoryGraph)
mg.ag = ag
mg.graph_name = graph_name
mg.llm_provider = "openai"
mg.user_id = None
mg.threshold = 0.99
mg.embedding_model = _make_deterministic_embedder()
mg.llm = MagicMock()
mg.config = MagicMock()
mg.config.graph_store.custom_prompt = None
try:
ag.execCypher("MATCH (n) DETACH DELETE n")
ag.commit()
except Exception:
ag.rollback()
yield mg
try:
ag.execCypher("MATCH (n) DETACH DELETE n")
ag.commit()
except Exception:
ag.rollback()
ag.close()
def _age_node_count(mg):
cursor = mg.ag.execCypher("MATCH (n) RETURN count(n)", cols=["cnt"])
rows = cursor.fetchall()
return rows[0][0] if rows else 0
def _age_edge_count(mg):
cursor = mg.ag.execCypher("MATCH ()-[r]->() RETURN count(r)", cols=["cnt"])
rows = cursor.fetchall()
return rows[0][0] if rows else 0
@requires_age
class TestApacheAgeDeleteE2E:
def test_add_creates_graph_data(self, age_graph):
mg = age_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert _age_node_count(mg) == 2
assert _age_edge_count(mg) == 1
def test_delete_hard_deletes_relationships(self, age_graph):
"""Apache AGE delete() should hard-delete the relationship."""
mg = age_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert _age_edge_count(mg) == 1
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert _age_edge_count(mg) == 0
assert _age_node_count(mg) == 2
def test_delete_preserves_other_relationships(self, age_graph):
mg = age_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}],
[{"source": "Alice", "destination": "Charlie", "relationship": "knows"}],
)
mg.add("Alice knows Charlie", {"user_id": "u1"})
assert _age_edge_count(mg) == 2
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert _age_edge_count(mg) == 1
def test_delete_user_isolation(self, age_graph):
mg = age_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
mg.add("Alice likes Bob", {"user_id": "u2"})
assert _age_edge_count(mg) == 2
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert _age_edge_count(mg) == 1
def test_delete_all_hard_deletes(self, age_graph):
mg = age_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert _age_node_count(mg) == 2
mg.delete_all({"user_id": "u1"})
assert _age_node_count(mg) == 0
assert _age_edge_count(mg) == 0
def test_add_delete_add_cycle(self, age_graph):
mg = age_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
mg.add("Alice likes Bob", {"user_id": "u1"})
assert _age_edge_count(mg) == 1
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert _age_edge_count(mg) == 0
mg.add("Alice likes Bob", {"user_id": "u1"})
assert _age_edge_count(mg) == 1
# ===========================================================================
# NEPTUNE (tested via Neo4j OpenCypher — same query language)
# ===========================================================================
def _neptune_test_available():
"""Neptune uses OpenCypher — we test NeptuneBase.delete() against Neo4j."""
if not _port_open("localhost", 7687):
return False
try:
# Mock langchain_aws so NeptuneBase can be imported without AWS deps
sys.modules.setdefault("langchain_aws", MagicMock())
sys.modules.setdefault("botocore", MagicMock())
sys.modules.setdefault("botocore.config", MagicMock())
from mem0.graphs.neptune.base import NeptuneBase # noqa: F401
from langchain_neo4j import Neo4jGraph
g = Neo4jGraph(
url="bolt://localhost:7687",
username="neo4j",
password="testpassword",
refresh_schema=False,
driver_config={"notifications_min_severity": "OFF"},
)
g.query("RETURN 1")
return True
except Exception:
return False
requires_neptune_test = pytest.mark.skipif(
not _neptune_test_available(),
reason="Neo4j not available (used as OpenCypher backend for Neptune tests)",
)
def _make_concrete_neptune_subclass():
"""Create a concrete NeptuneBase subclass for testing, backed by Neo4j."""
# Ensure mocks are in place for import
sys.modules.setdefault("langchain_aws", MagicMock())
sys.modules.setdefault("botocore", MagicMock())
sys.modules.setdefault("botocore.config", MagicMock())
from mem0.graphs.neptune.base import NeptuneBase
class TestableNeptune(NeptuneBase):
def __init__(self):
pass
def _delete_entities_cypher(self, source, destination, relationship, user_id):
cypher = f"""
MATCH (n:`__Entity__` {{name: $source_name, user_id: $user_id}})
-[r:{relationship}]->
(m:`__Entity__` {{name: $dest_name, user_id: $user_id}})
DELETE r
RETURN n.name AS source, m.name AS target, type(r) AS relationship
"""
return cypher, {"source_name": source, "dest_name": destination, "user_id": user_id}
def _delete_all_cypher(self, filters):
return (
"MATCH (n:`__Entity__` {user_id: $user_id}) DETACH DELETE n",
{"user_id": filters["user_id"]},
)
# Stubs for abstract methods not used in delete path
def _add_entities_by_source_cypher(self, *a, **kw): pass
def _add_entities_by_destination_cypher(self, *a, **kw): pass
def _add_relationship_entities_cypher(self, *a, **kw): pass
def _add_new_entities_cypher(self, *a, **kw): pass
def _search_source_node_cypher(self, *a, **kw): pass
def _search_destination_node_cypher(self, *a, **kw): pass
def _get_all_cypher(self, *a, **kw): pass
def _search_graph_db_cypher(self, *a, **kw): pass
return TestableNeptune
@pytest.fixture
def neptune_graph():
"""NeptuneBase subclass backed by a real Neo4j container."""
from langchain_neo4j import Neo4jGraph
cls = _make_concrete_neptune_subclass()
mg = cls()
mg.graph = Neo4jGraph(
url="bolt://localhost:7687",
username="neo4j",
password="testpassword",
refresh_schema=False,
driver_config={"notifications_min_severity": "OFF"},
)
mg.graph.query("MATCH (n) DETACH DELETE n")
mg.node_label = ":`__Entity__`"
mg.llm_provider = "openai"
mg.user_id = None
mg.threshold = 0.99
mg.embedding_model = _make_deterministic_embedder()
mg.llm = MagicMock()
mg.config = MagicMock()
mg.config.graph_store.custom_prompt = None
yield mg
mg.graph.query("MATCH (n) DETACH DELETE n")
def _neptune_node_count(mg):
return mg.graph.query("MATCH (n) RETURN count(n) AS cnt")[0]["cnt"]
def _neptune_edge_count(mg):
return mg.graph.query("MATCH ()-[r]->() RETURN count(r) AS cnt")[0]["cnt"]
def _neptune_create_entities(mg, user_id):
"""Create test entities directly via Cypher."""
mg.graph.query(f"""
CREATE (a:`__Entity__` {{name: 'alice', user_id: '{user_id}'}})
CREATE (b:`__Entity__` {{name: 'bob', user_id: '{user_id}'}})
CREATE (a)-[:likes]->(b)
""")
@requires_neptune_test
class TestNeptuneDeleteE2E:
"""Test NeptuneBase.delete() using Neo4j as the OpenCypher backend.
Neptune uses standard OpenCypher, the same query language as Neo4j.
This validates that:
- NeptuneBase.delete() correctly calls _delete_entities(to_be_deleted, user_id) with a string
- The generated Cypher from _delete_entities_cypher runs correctly
- User isolation works
"""
def test_delete_removes_relationship(self, neptune_graph):
mg = neptune_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
_neptune_create_entities(mg, "u1")
assert _neptune_edge_count(mg) == 1
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert _neptune_edge_count(mg) == 0
assert _neptune_node_count(mg) == 2
def test_delete_user_isolation(self, neptune_graph):
mg = neptune_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
_neptune_create_entities(mg, "u1")
_neptune_create_entities(mg, "u2")
assert _neptune_edge_count(mg) == 2
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert _neptune_edge_count(mg) == 1
def test_delete_passes_user_id_string_not_dict(self, neptune_graph):
"""Verify NeptuneBase.delete() passes filters['user_id'] (string) to _delete_entities."""
mg = neptune_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
original = mg._delete_entities
call_args = []
def spy(to_be_deleted, user_id):
call_args.append(("to_be_deleted", to_be_deleted, "user_id", user_id))
return original(to_be_deleted, user_id)
mg._delete_entities = spy
_neptune_create_entities(mg, "u1")
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert len(call_args) == 1
assert call_args[0][3] == "u1"
assert isinstance(call_args[0][3], str)
def test_delete_all(self, neptune_graph):
mg = neptune_graph
_neptune_create_entities(mg, "u1")
assert _neptune_node_count(mg) == 2
mg.delete_all({"user_id": "u1"})
assert _neptune_node_count(mg) == 0
def test_add_delete_add_cycle(self, neptune_graph):
mg = neptune_graph
mg.llm = _make_mock_llm(
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
)
_neptune_create_entities(mg, "u1")
assert _neptune_edge_count(mg) == 1
mg.delete("Alice likes Bob", {"user_id": "u1"})
assert _neptune_edge_count(mg) == 0
_neptune_create_entities(mg, "u1")
assert _neptune_edge_count(mg) == 1
+88 -39
View File
@@ -60,7 +60,7 @@ def memory_custom_instance():
config = MemoryConfig(
version="v1.1",
custom_fact_extraction_prompt="custom prompt extracting memory",
custom_instructions="custom prompt extracting memory in json format",
custom_update_memory_prompt="custom prompt determining memory update",
)
config.graph_store.config = {"some_config": "value"}
@@ -70,7 +70,8 @@ def memory_custom_instance():
@pytest.mark.parametrize("version, enable_graph", [("v1.0", False), ("v1.1", True)])
def test_add(memory_instance, version, enable_graph):
memory_instance.config.version = version
memory_instance.enable_graph = enable_graph
if not enable_graph:
memory_instance.graph = None
memory_instance._add_to_vector_store = Mock(return_value=[{"memory": "Test memory", "event": "ADD"}])
memory_instance._add_to_graph = Mock(return_value=[])
@@ -123,7 +124,8 @@ def test_get(memory_instance):
@pytest.mark.parametrize("version, enable_graph", [("v1.0", False), ("v1.1", True)])
def test_search(memory_instance, version, enable_graph):
memory_instance.config.version = version
memory_instance.enable_graph = enable_graph
if not enable_graph:
memory_instance.graph = None
mock_memories = [
Mock(id="1", payload={"data": "Memory 1", "user_id": "test_user"}, score=0.9),
Mock(id="2", payload={"data": "Memory 2", "user_id": "test_user"}, score=0.8),
@@ -131,7 +133,8 @@ def test_search(memory_instance, version, enable_graph):
memory_instance.vector_store.search = Mock(return_value=mock_memories)
memory_instance.vector_store.keyword_search = Mock(return_value=None) # No BM25
memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
memory_instance.graph.search = Mock(return_value=[{"relation": "test_relation"}])
if memory_instance.graph:
memory_instance.graph.search = Mock(return_value=[{"relation": "test_relation"}])
with patch("mem0.memory.main.lemmatize_for_bm25", return_value="test query"), \
patch("mem0.memory.main.extract_entities", return_value=[]):
@@ -154,13 +157,11 @@ def test_search(memory_instance, version, enable_graph):
# Hybrid pipeline over-fetches: max(100*4, 60) = 400
memory_instance.vector_store.search.assert_called_once_with(
query="test query", vectors=[0.1, 0.2, 0.3], limit=400, filters={"user_id": "test_user"}
query="test query", vectors=[0.1, 0.2, 0.3], top_k=400, filters={"user_id": "test_user"}
)
if enable_graph:
memory_instance.graph.search.assert_called_once_with("test query", {"user_id": "test_user"}, 100)
else:
memory_instance.graph.search.assert_not_called()
def test_update(memory_instance):
@@ -172,38 +173,72 @@ def test_update(memory_instance):
result = memory_instance.update("test_id", "Updated memory")
memory_instance._update_memory.assert_called_once_with(
"test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}
"test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, None
)
assert result["message"] == "Memory updated successfully!"
def test_update_with_metadata(memory_instance):
memory_instance.embedding_model = Mock()
memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
memory_instance._update_memory = Mock()
metadata = {"category": "sports", "priority": "high"}
result = memory_instance.update("test_id", "Updated memory", metadata=metadata)
memory_instance._update_memory.assert_called_once_with(
"test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, metadata
)
assert result["message"] == "Memory updated successfully!"
def test_update_with_empty_metadata(memory_instance):
memory_instance.embedding_model = Mock()
memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
memory_instance._update_memory = Mock()
memory_instance.update("test_id", "Updated memory", metadata={})
memory_instance._update_memory.assert_called_once_with(
"test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, {}
)
def test_delete(memory_instance):
memory_instance._delete_memory = Mock()
result = memory_instance.delete("test_id")
memory_instance._delete_memory.assert_called_once_with("test_id")
# delete() now fetches the memory first and passes it to _delete_memory
existing_memory = memory_instance.vector_store.get.return_value
memory_instance._delete_memory.assert_called_once_with("test_id", existing_memory)
assert result["message"] == "Memory deleted successfully!"
@pytest.mark.parametrize("version, enable_graph", [("v1.0", False), ("v1.1", True)])
def test_delete_all(memory_instance, version, enable_graph):
memory_instance.config.version = version
memory_instance.enable_graph = enable_graph
if not enable_graph:
memory_instance.graph = None
mock_memories = [Mock(id="1"), Mock(id="2")]
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
memory_instance.vector_store.reset = Mock()
memory_instance._delete_memory = Mock()
memory_instance.graph.delete_all = Mock()
if memory_instance.graph:
memory_instance.graph.delete_all = Mock()
result = memory_instance.delete_all(user_id="test_user")
assert memory_instance._delete_memory.call_count == 2
# Ensure the collection is NOT dropped — only matched memories should be removed
memory_instance.vector_store.reset.assert_not_called()
if enable_graph:
memory_instance.graph.delete_all.assert_called_once_with({"user_id": "test_user"})
else:
memory_instance.graph.delete_all.assert_not_called()
assert result["message"] == "Memories deleted successfully!"
@@ -225,12 +260,14 @@ def test_delete_all(memory_instance, version, enable_graph):
)
def test_get_all(memory_instance, version, enable_graph, expected_result):
memory_instance.config.version = version
memory_instance.enable_graph = enable_graph
if not enable_graph:
memory_instance.graph = None
mock_memories = [Mock(id="1", payload={"data": "Memory 1", "user_id": "test_user"})]
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
memory_instance.graph.get_all = Mock(
return_value=[{"source": "entity1", "relationship": "rel", "target": "entity2"}]
)
if memory_instance.graph:
memory_instance.graph.get_all = Mock(
return_value=[{"source": "entity1", "relationship": "rel", "target": "entity2"}]
)
result = memory_instance.get_all(user_id="test_user")
@@ -249,35 +286,47 @@ def test_get_all(memory_instance, version, enable_graph, expected_result):
else:
assert "relations" not in result
memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "test_user"}, limit=100)
memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "test_user"}, top_k=100)
if enable_graph:
memory_instance.graph.get_all.assert_called_once_with({"user_id": "test_user"}, 100)
else:
memory_instance.graph.get_all.assert_not_called()
def test_custom_prompts(memory_custom_instance):
"""Test that custom_fact_extraction_prompt is passed as custom_instructions in v3 pipeline."""
messages = [{"role": "user", "content": "Test message"}]
from mem0.embeddings.mock import MockEmbeddings
def test_no_telemetry_vector_store_when_disabled():
"""VectorStoreFactory should only be called once (for user data) when telemetry is disabled."""
with (
patch("mem0.memory.main.MEM0_TELEMETRY", False),
patch("mem0.utils.factory.EmbedderFactory") as mock_embedder,
patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store,
patch("mem0.utils.factory.LlmFactory") as mock_llm,
patch("mem0.memory.telemetry.capture_event"),
):
mock_embedder.create.return_value = Mock()
mock_vector_store.create.return_value = Mock()
mock_llm.create.return_value = Mock()
# V3 pipeline returns {"memory": [...]} format (single LLM call)
memory_custom_instance.llm.generate_response = Mock()
memory_custom_instance.llm.generate_response.return_value = '{"memory": []}'
memory_custom_instance.embedding_model = MockEmbeddings()
memory_custom_instance.vector_store.search = Mock(return_value=[])
config = MemoryConfig(version="v1.1")
Memory(config)
with patch("mem0.memory.main.parse_messages", return_value="Test message") as mock_parse_messages:
memory_custom_instance.add(messages=messages, user_id="test_user")
# VectorStoreFactory.create should be called exactly once — for user data only, not telemetry
assert mock_vector_store.create.call_count == 1
mock_parse_messages.assert_called_once_with(messages)
# V3 pipeline makes exactly ONE LLM call (not two)
assert memory_custom_instance.llm.generate_response.call_count == 1
def test_telemetry_vector_store_created_when_enabled():
"""VectorStoreFactory should be called twice (user data + telemetry) when telemetry is enabled."""
with (
patch("mem0.memory.main.MEM0_TELEMETRY", True),
patch("mem0.utils.factory.EmbedderFactory") as mock_embedder,
patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store,
patch("mem0.utils.factory.LlmFactory") as mock_llm,
patch("mem0.memory.telemetry.capture_event"),
):
mock_embedder.create.return_value = Mock()
mock_vector_store.create.return_value = Mock()
mock_llm.create.return_value = Mock()
# The system prompt should be ADDITIVE_EXTRACTION_PROMPT
call_args = memory_custom_instance.llm.generate_response.call_args
llm_messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][0]
assert llm_messages[0]["role"] == "system"
assert "Memory Extractor" in llm_messages[0]["content"] # From ADDITIVE_EXTRACTION_PROMPT
config = MemoryConfig(version="v1.1")
Memory(config)
# VectorStoreFactory.create should be called twice — user data + telemetry
assert mock_vector_store.create.call_count == 2
+592 -5
View File
@@ -1,14 +1,17 @@
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from mem0 import Memory
from mem0.configs.base import MemoryConfig
from mem0.memory.utils import normalize_facts
class MockVectorMemory:
"""Mock memory object for testing incomplete payloads."""
def __init__(self, memory_id: str, payload: dict, score: float = 0.8):
self.id = memory_id
self.payload = payload
@@ -102,7 +105,7 @@ def test_collection_name_preserved_after_reset(mock_sqlite, mock_llm_factory, mo
reset_calls = [call for call in mock_vector_factory.call_args_list if len(mock_vector_factory.call_args_list) > 2]
if reset_calls:
reset_config = reset_calls[-1][0][1]
reset_config = reset_calls[-1][0][1]
assert reset_config.collection_name == test_collection_name, f"Reset used wrong collection name: {reset_config.collection_name}"
@@ -127,13 +130,13 @@ def test_search_handles_incomplete_payloads(mock_sqlite, mock_llm_factory, mock_
complete_memory = MockVectorMemory("mem_2", {"data": "content", "hash": "def456"})
mock_vector_store.search.return_value = [incomplete_memory, complete_memory]
mock_embedder = MagicMock()
mock_embedder.embed.return_value = [0.1, 0.2, 0.3]
memory.embedding_model = mock_embedder
result = memory._search_vector_store("test", {"user_id": "test"}, 10)
assert len(result) == 2
memories_by_id = {mem["id"]: mem for mem in result}
@@ -244,4 +247,588 @@ def test_get_all_handles_flat_list_from_postgres(mock_sqlite, mock_llm_factory,
assert len(result) == 2
assert result[0]["memory"] == "Memory 1"
assert result[1]["memory"] == "Memory 2"
assert result[1]["memory"] == "Memory 2"
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_with_malformed_llm_facts(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Repro for: 'list' object has no attribute 'replace' on infer=true.
When an LLM (especially smaller models like llama3.1:8b) returns facts as
objects ({"fact": "..."} or {"text": "..."}) instead of plain strings,
the embedding model's .replace() call crashes with AttributeError.
"""
mock_embedder = MagicMock()
mock_embedder.embed.side_effect = lambda text, action: (_ for _ in ()).throw(
AttributeError("'dict' object has no attribute 'replace'")
) if not isinstance(text, str) else [0.1, 0.2, 0.3]
mock_embedder_factory.return_value = mock_embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_factory.return_value = mock_vector_store
# LLM returns malformed facts: dicts instead of strings
malformed_response = json.dumps({
"facts": [
{"fact": "User likes Python"},
{"text": "User is a developer"},
]
})
mock_llm = MagicMock()
mock_llm.generate_response.return_value = malformed_response
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
# This should NOT raise AttributeError
memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like Python and I'm a developer"}],
metadata={"user_id": "test_user"},
filters={"user_id": "test_user"},
infer=True,
)
def test_normalize_facts_plain_strings():
assert normalize_facts(["fact one", "fact two"]) == ["fact one", "fact two"]
def test_normalize_facts_dict_with_fact_key():
assert normalize_facts([{"fact": "User likes Python"}]) == ["User likes Python"]
def test_normalize_facts_dict_with_text_key():
assert normalize_facts([{"text": "User is a developer"}]) == ["User is a developer"]
def test_normalize_facts_mixed():
raw = [
"plain string",
{"fact": "from fact key"},
{"text": "from text key"},
]
assert normalize_facts(raw) == ["plain string", "from fact key", "from text key"]
def test_normalize_facts_filters_empty_strings():
assert normalize_facts(["", "valid", ""]) == ["valid"]
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_delete_nonexistent_memory_raises_error(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that delete() raises ValueError when memory_id does not exist
and does not attempt to delete from the vector store.
Issue #3849: memory.delete() fails with AttributeError when memory not found.
Should raise a clear ValueError instead.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
mock_vector_store.get.return_value = None
with pytest.raises(ValueError, match="Memory with id non-existent-id not found"):
memory.delete("non-existent-id")
mock_vector_store.delete.assert_not_called()
@pytest.mark.asyncio
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
async def test_async_delete_nonexistent_memory_raises_error(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that async delete() raises ValueError when memory_id does not exist
and does not attempt to delete from the vector store.
Issue #3849: memory.delete() fails with AttributeError when memory not found.
Should raise a clear ValueError instead.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_store.get.return_value = None
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import AsyncMemory
config = MemoryConfig()
memory = AsyncMemory(config)
with pytest.raises(ValueError, match="Memory with id non-existent-id not found"):
await memory.delete("non-existent-id")
mock_vector_store.delete.assert_not_called()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_update_nonexistent_memory_raises_error(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that _update_memory() raises ValueError when memory_id does not exist.
Same class of bug as #3849 — vector_store.get() returns None and code
accesses .payload without a null check.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
mock_vector_store.get.return_value = None
with pytest.raises(ValueError, match="Memory with id non-existent-id not found"):
memory._update_memory("non-existent-id", "new data", {"new data": [0.1, 0.2]})
mock_vector_store.update.assert_not_called()
@pytest.mark.asyncio
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
async def test_async_update_nonexistent_memory_raises_error(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that async _update_memory() raises ValueError when memory_id does not exist.
Same class of bug as #3849 — vector_store.get() returns None and code
accesses .payload without a null check.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_store.get.return_value = None
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import AsyncMemory
config = MemoryConfig()
memory = AsyncMemory(config)
with pytest.raises(ValueError, match="Memory with id non-existent-id not found"):
await memory._update_memory("non-existent-id", "new data", {"new data": [0.1, 0.2]})
mock_vector_store.update.assert_not_called()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_false_embeds_once(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Regression test for issue #3723: adding with infer=False should not trigger duplicate embedding calls.
Root cause: _create_memory expected a dict for existing_embeddings but received a raw list[float],
causing the cache check `data in existing_embeddings` to always fail and trigger a redundant embed.
"""
embedder = MagicMock()
embedder.embed.return_value = [0.1, 0.2, 0.3]
embedder.config = MagicMock(embedding_dims=3)
mock_embedder_factory.return_value = embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_store.insert.return_value = None
mock_vector_store.get.return_value = None
telemetry_vector_store = MagicMock()
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
memory = MemoryClass(MemoryConfig())
memory.add("foo", user_id="test_user", infer=False)
assert embedder.embed.call_count == 1
mock_vector_store.insert.assert_called_once()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Regression test for issue #3723 (infer=True path): when the LLM rewrites a fact during the
ADD action, the embedding should be computed once and cached, not computed again inside _create_memory.
"""
embedder = MagicMock()
embedder.embed.return_value = [0.1, 0.2, 0.3]
embedder.config = MagicMock(embedding_dims=3)
mock_embedder_factory.return_value = embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_store.insert.return_value = None
mock_vector_store.get.return_value = None
telemetry_vector_store = MagicMock()
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
# LLM extracts fact "User likes Python", then ADD action rewrites to "The user enjoys Python"
mock_llm = MagicMock()
mock_llm.generate_response.side_effect = [
json.dumps({"facts": ["User likes Python"]}),
json.dumps({"memory": [{"id": "0", "text": "The user enjoys Python", "event": "ADD", "old_memory": None}]}),
]
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
memory = MemoryClass(MemoryConfig())
memory.add("I like Python", user_id="test_user", infer=True)
# embed should be called exactly twice:
# 1. For the extracted fact "User likes Python" (search)
# 2. For the rewritten text "The user enjoys Python" (pre-cached before _create_memory)
# It should NOT be called a 3rd time inside _create_memory
assert embedder.embed.call_count == 2
mock_vector_store.insert.assert_called_once()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_update_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Regression test for issue #3723 (infer=True UPDATE path): when the LLM rewrites a fact during
an UPDATE action, the embedding should be computed once and cached, not computed again inside _update_memory.
"""
embedder = MagicMock()
embedder.embed.return_value = [0.1, 0.2, 0.3]
embedder.config = MagicMock(embedding_dims=3)
mock_embedder_factory.return_value = embedder
# Existing memory that will be matched for update
existing_memory = MockVectorMemory(
memory_id="existing-mem-id",
payload={
"data": "User likes Python",
"hash": "abc123",
"created_at": "2025-01-01T00:00:00+00:00",
},
)
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = [existing_memory]
mock_vector_store.get.return_value = existing_memory
mock_vector_store.insert.return_value = None
mock_vector_store.update.return_value = None
telemetry_vector_store = MagicMock()
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
# LLM extracts fact "User loves Python now", then UPDATE action rewrites to "The user loves Python"
mock_llm = MagicMock()
mock_llm.generate_response.side_effect = [
json.dumps({"facts": ["User loves Python now"]}),
json.dumps({"memory": [{"id": "0", "text": "The user loves Python", "event": "UPDATE", "old_memory": "User likes Python"}]}),
]
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
memory = MemoryClass(MemoryConfig())
memory.add("I love Python now", user_id="test_user", infer=True)
# embed should be called exactly twice:
# 1. For the extracted fact "User loves Python now" (search)
# 2. For the rewritten text "The user loves Python" (pre-cached before _update_memory)
# It should NOT be called a 3rd time inside _update_memory
assert embedder.embed.call_count == 2
mock_vector_store.update.assert_called_once()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
def test_delete_memory_history_has_timestamps(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that deleting a memory records created_at and updated_at in history.
Ensures DELETE operations preserve the original creation timestamp
and record the deletion time for proper audit trails.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"created_at": "2024-01-01T00:00:00+00:00",
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] == "2024-01-01T00:00:00+00:00"
assert call_kwargs["updated_at"] is not None
datetime.fromisoformat(call_kwargs["updated_at"]) # verify valid ISO timestamp
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
def test_delete_memory_normalizes_non_utc_created_at(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that non-UTC created_at timestamps are normalized to UTC on delete."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"created_at": "2024-01-01T05:00:00+05:00", # UTC+5
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] == "2024-01-01T00:00:00+00:00" # normalized to UTC
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
def test_delete_memory_missing_created_at(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that delete works when created_at is absent from the payload (pre-existing memories)."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] is None
assert call_kwargs["updated_at"] is not None
datetime.fromisoformat(call_kwargs["updated_at"]) # verify valid ISO timestamp
@pytest.mark.asyncio
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
async def test_async_delete_memory_history_has_timestamps(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that async deleting a memory records created_at and updated_at in history.
Ensures async DELETE operations preserve the original creation timestamp
and record the deletion time for proper audit trails.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import AsyncMemory
config = MemoryConfig()
memory = AsyncMemory(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"created_at": "2024-01-01T00:00:00+00:00",
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
await memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] == "2024-01-01T00:00:00+00:00"
assert call_kwargs["updated_at"] is not None
datetime.fromisoformat(call_kwargs["updated_at"]) # verify valid ISO timestamp
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
class TestProcessMetadataFiltersMerge:
"""Regression tests for issue #3952: multiple operators on the same key must be merged."""
def _make_memory(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
mock_embedder_factory.return_value = MagicMock()
mock_vector_factory.return_value = MagicMock()
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
return MemoryClass(MemoryConfig())
def test_multiple_operators_same_key_merged(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Filters like created_at: {gte: X, lte: Y} must preserve both operators."""
memory = self._make_memory(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory)
result = memory._process_metadata_filters({
"created_at": {"gte": 1000, "lte": 2000}
})
assert result == {"created_at": {"gte": 1000, "lte": 2000}}
def test_single_operator_still_works(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Single operator filters must continue to work."""
memory = self._make_memory(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory)
result = memory._process_metadata_filters({
"created_at": {"gte": 1000}
})
assert result == {"created_at": {"gte": 1000}}
def test_multiple_keys_with_multiple_operators(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Multiple keys each with multiple operators."""
memory = self._make_memory(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory)
result = memory._process_metadata_filters({
"created_at": {"gte": 1000, "lte": 2000},
"score": {"gt": 0.5, "lt": 0.9},
})
assert result == {
"created_at": {"gte": 1000, "lte": 2000},
"score": {"gt": 0.5, "lt": 0.9},
}
# --- Issue #3040: reset() should clean up graph database ---
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_reset_calls_graph_reset_when_graph_enabled(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that reset() calls graph.reset() when graph is enabled (issue #3040)."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
config = MemoryConfig()
memory = Memory(config)
# Simulate graph being enabled by setting graph instance
mock_graph = MagicMock()
memory.graph = mock_graph
memory.reset()
mock_graph.reset.assert_called_once()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_reset_skips_graph_when_graph_disabled(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that reset() does NOT call graph.reset() when graph is disabled."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
config = MemoryConfig()
memory = Memory(config)
# Graph is disabled by default (graph is None)
memory.graph = None
memory.reset()
# graph should remain None after reset
assert memory.graph is None
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_reset_continues_if_graph_reset_fails(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that reset() doesn't crash if graph.reset() raises an exception (issue #3040)."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
config = MemoryConfig()
memory = Memory(config)
mock_graph = MagicMock()
mock_graph.reset.side_effect = Exception("Neo4j connection failed")
memory.graph = mock_graph
# Should NOT raise — graph failure is logged but reset continues
memory.reset()
mock_graph.reset.assert_called_once()
+496
View File
@@ -0,0 +1,496 @@
"""Comprehensive E2E tests for REST API server authentication.
Tests the actual server/main.py app through FastAPI's TestClient (full ASGI
round-trip) covering:
- Auth disabled mode (ADMIN_API_KEY unset)
- Auth enabled mode (ADMIN_API_KEY set)
- Edge cases: empty keys, near-miss keys, timing-safe comparison, header
casing, response headers, startup logging, and full CRUD flows through auth.
"""
import importlib
import logging
import os
from unittest.mock import MagicMock, patch
import pytest
pytest.importorskip("fastapi", reason="fastapi not installed")
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def _mock_memory():
"""Patch Memory.from_config so the server imports without a real backend."""
mock_instance = MagicMock()
# Set up return values so CRUD endpoints return realistic responses
mock_instance.get.return_value = {"id": "mem-1", "memory": "test memory", "user_id": "alice"}
mock_instance.get_all.return_value = [
{"id": "mem-1", "memory": "test memory", "user_id": "alice"},
]
mock_instance.add.return_value = {"results": [{"id": "mem-1", "event": "ADD", "memory": "test"}]}
mock_instance.search.return_value = [{"id": "mem-1", "memory": "test", "score": 0.9}]
mock_instance.update.return_value = {"message": "Memory updated"}
mock_instance.history.return_value = [{"id": "mem-1", "old_memory": "a", "new_memory": "b"}]
mock_instance.delete.return_value = None
mock_instance.delete_all.return_value = {"message": "Memories deleted successfully!"}
mock_instance.reset.return_value = None
with patch.dict(os.environ, {"OPENAI_API_KEY": "fake-key"}):
with patch("mem0.Memory.from_config", return_value=mock_instance):
yield mock_instance
def _load_app(env_overrides: dict):
"""Reload server/main.py with the given environment and return the FastAPI app."""
import server.main as server_main
with patch.dict(os.environ, env_overrides, clear=False):
importlib.reload(server_main)
return server_main.app
# ---------------------------------------------------------------------------
# Auth disabled (ADMIN_API_KEY not set)
# ---------------------------------------------------------------------------
class TestAuthDisabled:
"""All endpoints should be freely accessible when ADMIN_API_KEY is empty."""
@pytest.fixture(autouse=True)
def _setup(self, _mock_memory):
self.app = _load_app({"ADMIN_API_KEY": ""})
self.client = TestClient(self.app)
self.mock = _mock_memory
def test_root_redirects_to_docs(self):
resp = self.client.get("/", follow_redirects=False)
assert resp.status_code == 307
assert "/docs" in resp.headers["location"]
def test_get_memory_without_key(self):
resp = self.client.get("/memories/mem-1")
assert resp.status_code == 200
assert resp.json()["id"] == "mem-1"
def test_get_all_memories_without_key(self):
resp = self.client.get("/memories", params={"user_id": "alice"})
assert resp.status_code == 200
def test_create_memory_without_key(self):
resp = self.client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "alice",
})
assert resp.status_code == 200
def test_search_without_key(self):
resp = self.client.post("/search", json={"query": "pizza", "user_id": "alice"})
assert resp.status_code == 200
def test_update_memory_without_key(self):
resp = self.client.put("/memories/mem-1", json={"text": "updated"})
assert resp.status_code == 200
def test_history_without_key(self):
resp = self.client.get("/memories/mem-1/history")
assert resp.status_code == 200
def test_delete_memory_without_key(self):
resp = self.client.delete("/memories/mem-1")
assert resp.status_code == 200
def test_delete_all_without_key(self):
resp = self.client.delete("/memories", params={"user_id": "alice"})
assert resp.status_code == 200
def test_reset_without_key(self):
resp = self.client.post("/reset")
assert resp.status_code == 200
def test_configure_without_key(self):
self.mock.from_config = MagicMock()
resp = self.client.post("/configure", json={"version": "v1.1"})
assert resp.status_code == 200
def test_supplying_key_still_works_when_auth_disabled(self):
"""A client that sends X-API-Key should not be penalized when auth is off."""
resp = self.client.get(
"/memories/mem-1", headers={"X-API-Key": "some-random-key"}
)
assert resp.status_code == 200
@pytest.mark.parametrize(
"method,path",
[
("POST", "/configure"),
("POST", "/memories"),
("GET", "/memories"),
("GET", "/memories/test-id"),
("POST", "/search"),
("PUT", "/memories/test-id"),
("GET", "/memories/test-id/history"),
("DELETE", "/memories/test-id"),
("DELETE", "/memories"),
("POST", "/reset"),
],
)
def test_no_endpoint_returns_401_when_auth_disabled(self, method, path):
resp = self.client.request(method, path)
assert resp.status_code != 401, f"{method} {path} should not require auth"
# ---------------------------------------------------------------------------
# Auth enabled (ADMIN_API_KEY set)
# ---------------------------------------------------------------------------
class TestAuthEnabled:
"""All protected endpoints must enforce the API key."""
API_KEY = "test-secret-key-12345"
@pytest.fixture(autouse=True)
def _setup(self, _mock_memory):
self.app = _load_app({"ADMIN_API_KEY": self.API_KEY})
self.client = TestClient(self.app)
self.mock = _mock_memory
# --- Rejection cases ---
def test_missing_key_returns_401(self):
resp = self.client.get("/memories/mem-1")
assert resp.status_code == 401
def test_missing_key_detail_mentions_header(self):
resp = self.client.get("/memories/mem-1")
assert "X-API-Key" in resp.json()["detail"]
def test_wrong_key_returns_401(self):
resp = self.client.get("/memories/mem-1", headers={"X-API-Key": "wrong"})
assert resp.status_code == 401
def test_wrong_key_detail_says_invalid(self):
resp = self.client.get("/memories/mem-1", headers={"X-API-Key": "wrong"})
assert "Invalid" in resp.json()["detail"]
def test_empty_string_key_returns_401(self):
resp = self.client.get("/memories/mem-1", headers={"X-API-Key": ""})
assert resp.status_code == 401
def test_401_includes_www_authenticate_header(self):
resp = self.client.get("/memories/mem-1")
assert resp.headers.get("www-authenticate") == "ApiKey"
def test_near_miss_key_rejected(self):
"""Key that differs by one character should be rejected."""
near_miss = self.API_KEY[:-1] + ("6" if self.API_KEY[-1] != "6" else "7")
resp = self.client.get("/memories/mem-1", headers={"X-API-Key": near_miss})
assert resp.status_code == 401
def test_key_with_extra_whitespace_rejected(self):
resp = self.client.get("/memories/mem-1", headers={"X-API-Key": f" {self.API_KEY} "})
assert resp.status_code == 401
def test_key_prefix_rejected(self):
resp = self.client.get("/memories/mem-1", headers={"X-API-Key": self.API_KEY[:5]})
assert resp.status_code == 401
def test_key_with_different_case_rejected(self):
resp = self.client.get("/memories/mem-1", headers={"X-API-Key": self.API_KEY.upper()})
assert resp.status_code == 401
@pytest.mark.parametrize(
"method,path",
[
("POST", "/configure"),
("POST", "/memories"),
("GET", "/memories"),
("GET", "/memories/test-id"),
("POST", "/search"),
("PUT", "/memories/test-id"),
("GET", "/memories/test-id/history"),
("DELETE", "/memories/test-id"),
("DELETE", "/memories"),
("POST", "/reset"),
],
)
def test_all_endpoints_reject_without_key(self, method, path):
resp = self.client.request(method, path)
assert resp.status_code == 401, f"{method} {path} should require auth"
@pytest.mark.parametrize(
"method,path",
[
("POST", "/configure"),
("POST", "/memories"),
("GET", "/memories"),
("GET", "/memories/test-id"),
("POST", "/search"),
("PUT", "/memories/test-id"),
("GET", "/memories/test-id/history"),
("DELETE", "/memories/test-id"),
("DELETE", "/memories"),
("POST", "/reset"),
],
)
def test_all_endpoints_reject_wrong_key(self, method, path):
resp = self.client.request(method, path, headers={"X-API-Key": "wrong-key"})
assert resp.status_code == 401, f"{method} {path} should reject wrong key"
# --- Acceptance cases ---
def test_root_does_not_require_key(self):
resp = self.client.get("/", follow_redirects=False)
assert resp.status_code == 307
def _authed(self, method, path, **kwargs):
headers = kwargs.pop("headers", {})
headers["X-API-Key"] = self.API_KEY
return self.client.request(method, path, headers=headers, **kwargs)
def test_get_memory_with_key(self):
resp = self._authed("GET", "/memories/mem-1")
assert resp.status_code == 200
assert resp.json()["id"] == "mem-1"
def test_get_all_memories_with_key(self):
resp = self._authed("GET", "/memories", params={"user_id": "alice"})
assert resp.status_code == 200
def test_create_memory_with_key(self):
resp = self._authed("POST", "/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "alice",
})
assert resp.status_code == 200
data = resp.json()
assert "results" in data
def test_search_with_key(self):
resp = self._authed("POST", "/search", json={"query": "pizza", "user_id": "alice"})
assert resp.status_code == 200
def test_update_memory_with_key(self):
resp = self._authed("PUT", "/memories/mem-1", json={"text": "updated"})
assert resp.status_code == 200
def test_history_with_key(self):
resp = self._authed("GET", "/memories/mem-1/history")
assert resp.status_code == 200
def test_delete_memory_with_key(self):
resp = self._authed("DELETE", "/memories/mem-1")
assert resp.status_code == 200
def test_delete_all_with_key(self):
resp = self._authed("DELETE", "/memories", params={"user_id": "alice"})
assert resp.status_code == 200
def test_reset_with_key(self):
resp = self._authed("POST", "/reset")
assert resp.status_code == 200
def test_configure_with_key(self):
resp = self._authed("POST", "/configure", json={"version": "v1.1"})
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Full CRUD flow through auth
# ---------------------------------------------------------------------------
class TestAuthenticatedCRUDFlow:
"""Verify a complete create → read → search → update → history → delete
cycle works end-to-end through the auth layer."""
API_KEY = "flow-test-key-99"
@pytest.fixture(autouse=True)
def _setup(self, _mock_memory):
self.app = _load_app({"ADMIN_API_KEY": self.API_KEY})
self.client = TestClient(self.app)
self.mock = _mock_memory
def _authed(self, method, path, **kwargs):
headers = kwargs.pop("headers", {})
headers["X-API-Key"] = self.API_KEY
return self.client.request(method, path, headers=headers, **kwargs)
def test_full_crud_cycle(self):
# 1. Create
resp = self._authed("POST", "/memories", json={
"messages": [{"role": "user", "content": "I love fresh vegetable pizza"}],
"user_id": "alice",
})
assert resp.status_code == 200
self.mock.add.assert_called_once()
# 2. Read single
resp = self._authed("GET", "/memories/mem-1")
assert resp.status_code == 200
self.mock.get.assert_called_once_with("mem-1")
# 3. Read all
resp = self._authed("GET", "/memories", params={"user_id": "alice"})
assert resp.status_code == 200
self.mock.get_all.assert_called_once_with(user_id="alice")
# 4. Search
resp = self._authed("POST", "/search", json={"query": "pizza", "user_id": "alice"})
assert resp.status_code == 200
self.mock.search.assert_called_once()
# 5. Update
resp = self._authed("PUT", "/memories/mem-1", json={"text": "updated content"})
assert resp.status_code == 200
self.mock.update.assert_called_once()
# 6. History
resp = self._authed("GET", "/memories/mem-1/history")
assert resp.status_code == 200
self.mock.history.assert_called_once_with(memory_id="mem-1")
# 7. Delete single
resp = self._authed("DELETE", "/memories/mem-1")
assert resp.status_code == 200
self.mock.delete.assert_called_once_with(memory_id="mem-1")
# 8. Delete all
resp = self._authed("DELETE", "/memories", params={"user_id": "alice"})
assert resp.status_code == 200
self.mock.delete_all.assert_called_once()
def test_crud_flow_blocked_without_auth(self):
"""Same flow should fail at every step without the key."""
endpoints = [
("POST", "/memories", {"json": {
"messages": [{"role": "user", "content": "test"}], "user_id": "alice"
}}),
("GET", "/memories/mem-1", {}),
("GET", "/memories", {"params": {"user_id": "alice"}}),
("POST", "/search", {"json": {"query": "pizza", "user_id": "alice"}}),
("PUT", "/memories/mem-1", {"json": {"data": "x"}}),
("GET", "/memories/mem-1/history", {}),
("DELETE", "/memories/mem-1", {}),
("DELETE", "/memories", {"params": {"user_id": "alice"}}),
("POST", "/reset", {}),
]
for method, path, kwargs in endpoints:
resp = self.client.request(method, path, **kwargs)
assert resp.status_code == 401, f"Unauthenticated {method} {path} should be 401"
# Verify the mock was NOT called (auth blocked before reaching handler)
self.mock.add.assert_not_called()
self.mock.get.assert_not_called()
self.mock.search.assert_not_called()
self.mock.update.assert_not_called()
self.mock.history.assert_not_called()
self.mock.delete.assert_not_called()
self.mock.delete_all.assert_not_called()
self.mock.reset.assert_not_called()
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestAuthEdgeCases:
"""Boundary conditions and unusual inputs."""
@pytest.fixture(autouse=True)
def _setup(self, _mock_memory):
self.mock = _mock_memory
def test_very_long_api_key(self):
"""Server should handle a very long key without crashing."""
long_key = "k" * 4096
app = _load_app({"ADMIN_API_KEY": long_key})
client = TestClient(app)
resp = client.get("/memories/mem-1", headers={"X-API-Key": long_key})
assert resp.status_code == 200
def test_special_characters_in_api_key(self):
"""Keys with special ASCII characters should work."""
special_key = "sk-!@#$%^&*()_+-=[]{}|;:',.<>?/~`"
app = _load_app({"ADMIN_API_KEY": special_key})
client = TestClient(app)
resp = client.get("/memories/mem-1", headers={"X-API-Key": special_key})
assert resp.status_code == 200
resp = client.get("/memories/mem-1", headers={"X-API-Key": "wrong"})
assert resp.status_code == 401
def test_key_env_var_not_present_at_all(self):
"""When the env var is completely absent, auth should be disabled."""
import server.main as server_main
env = os.environ.copy()
env.pop("ADMIN_API_KEY", None)
with patch.dict(os.environ, env, clear=True):
importlib.reload(server_main)
client = TestClient(server_main.app)
resp = client.get("/memories/mem-1")
assert resp.status_code != 401
def test_switching_from_enabled_to_disabled(self):
"""Simulates a server restart with auth toggled off."""
# First: auth enabled
app1 = _load_app({"ADMIN_API_KEY": "secret"})
c1 = TestClient(app1)
assert c1.get("/memories/mem-1").status_code == 401
# Then: auth disabled
app2 = _load_app({"ADMIN_API_KEY": ""})
c2 = TestClient(app2)
assert c2.get("/memories/mem-1").status_code != 401
def test_openapi_schema_accessible_without_key(self):
"""The /docs and /openapi.json endpoints should always be reachable."""
app = _load_app({"ADMIN_API_KEY": "secret"})
client = TestClient(app)
resp = client.get("/openapi.json")
assert resp.status_code == 200
schema = resp.json()
assert "paths" in schema
resp = client.get("/docs")
assert resp.status_code == 200
def test_openapi_schema_documents_auth(self):
"""The OpenAPI schema should mention authentication."""
app = _load_app({"ADMIN_API_KEY": "secret"})
client = TestClient(app)
schema = client.get("/openapi.json").json()
assert "Authentication" in schema.get("info", {}).get("description", "")
# ---------------------------------------------------------------------------
# Startup logging
# ---------------------------------------------------------------------------
class TestStartupLogging:
"""Verify the server emits the correct log messages at import time."""
@pytest.fixture(autouse=True)
def _setup(self, _mock_memory):
pass
def test_warning_when_auth_disabled(self, caplog):
with caplog.at_level(logging.WARNING):
_load_app({"ADMIN_API_KEY": ""})
assert any("UNSECURED" in r.message for r in caplog.records)
def test_info_when_auth_enabled(self, caplog):
with caplog.at_level(logging.INFO):
_load_app({"ADMIN_API_KEY": "a-long-enough-secret-key"})
assert any("authentication enabled" in r.message for r in caplog.records)
def test_warning_when_key_too_short(self, caplog):
with caplog.at_level(logging.WARNING):
_load_app({"ADMIN_API_KEY": "short"})
assert any("shorter than" in r.message for r in caplog.records)
+563
View File
@@ -0,0 +1,563 @@
"""Tests for REST API parameter forwarding.
Verifies that the Pydantic request models in server/main.py correctly accept
and forward all parameters supported by the underlying Memory class methods,
including limit, threshold, infer, memory_type, and prompt — which were
previously silently dropped by Pydantic v2's default extra='ignore' behavior.
"""
import importlib
import os
from unittest.mock import MagicMock, patch
import pytest
pytest.importorskip("fastapi", reason="fastapi not installed")
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def _mock_memory():
"""Patch Memory.from_config so the server imports without a real backend."""
mock_instance = MagicMock()
mock_instance.add.return_value = {"results": [{"id": "mem-1", "event": "ADD", "memory": "test"}]}
mock_instance.search.return_value = [{"id": "mem-1", "memory": "test", "score": 0.9}]
mock_instance.get.return_value = {"id": "mem-1", "memory": "test memory"}
mock_instance.get_all.return_value = [{"id": "mem-1", "memory": "test memory"}]
mock_instance.update.return_value = {"message": "Memory updated"}
mock_instance.history.return_value = [{"id": "mem-1", "old_memory": "a", "new_memory": "b"}]
mock_instance.delete.return_value = None
mock_instance.delete_all.return_value = {"message": "Memories deleted"}
mock_instance.reset.return_value = None
with patch.dict(os.environ, {"OPENAI_API_KEY": "fake-key", "ADMIN_API_KEY": ""}):
with patch("mem0.Memory.from_config", return_value=mock_instance):
yield mock_instance
@pytest.fixture
def client(_mock_memory):
"""Return a TestClient wired to the server app with mocked Memory."""
import server.main as server_main
with patch.dict(os.environ, {"ADMIN_API_KEY": ""}):
importlib.reload(server_main)
return TestClient(server_main.app)
@pytest.fixture
def mock_memory(_mock_memory):
return _mock_memory
# ===========================================================================
# SearchRequest: limit parameter
# ===========================================================================
class TestSearchLimit:
"""Verify that the limit parameter is accepted and forwarded to Memory.search()."""
def test_limit_forwarded(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "limit": 5})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["limit"] == 5
def test_limit_one(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "limit": 1})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["limit"] == 1
def test_limit_omitted_uses_memory_default(self, client, mock_memory):
"""When limit is not sent, it should not appear in the kwargs,
allowing Memory.search() to use its own default (100)."""
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "limit" not in kwargs
# ===========================================================================
# SearchRequest: threshold parameter
# ===========================================================================
class TestSearchThreshold:
"""Verify that the threshold parameter is accepted and forwarded."""
def test_threshold_forwarded(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "threshold": 0.8})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["threshold"] == 0.8
def test_threshold_zero(self, client, mock_memory):
"""threshold=0.0 is a valid falsy value that must not be filtered out."""
resp = client.post("/search", json={"query": "food", "user_id": "u1", "threshold": 0.0})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["threshold"] == 0.0
def test_threshold_omitted_uses_memory_default(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "threshold" not in kwargs
# ===========================================================================
# SearchRequest: limit + threshold together
# ===========================================================================
class TestSearchLimitAndThreshold:
def test_both_forwarded(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "limit": 10, "threshold": 0.5
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["limit"] == 10
assert kwargs["threshold"] == 0.5
# ===========================================================================
# MemoryCreate: infer parameter
# ===========================================================================
class TestAddInfer:
"""Verify that the infer parameter is accepted and forwarded to Memory.add()."""
def test_infer_false_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "Store this exactly"}],
"user_id": "u1",
"infer": False,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is False
def test_infer_true_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"infer": True,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is True
def test_infer_omitted_uses_memory_default(self, client, mock_memory):
"""When infer is not sent, it should not appear in kwargs,
allowing Memory.add() to use its own default (True)."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hello"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "infer" not in kwargs
# ===========================================================================
# MemoryCreate: memory_type parameter
# ===========================================================================
class TestAddMemoryType:
"""Verify that the memory_type parameter is accepted and forwarded."""
def test_memory_type_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"memory_type": "core",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["memory_type"] == "core"
def test_memory_type_omitted(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hello"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "memory_type" not in kwargs
# ===========================================================================
# MemoryCreate: prompt parameter
# ===========================================================================
class TestAddPrompt:
"""Verify that the prompt parameter is accepted and forwarded."""
def test_prompt_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"prompt": "Extract food preferences only.",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["prompt"] == "Extract food preferences only."
def test_prompt_omitted(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hello"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "prompt" not in kwargs
# ===========================================================================
# MemoryCreate: all new params together
# ===========================================================================
class TestAddAllNewParams:
def test_infer_memory_type_and_prompt_together(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"infer": False,
"memory_type": "core",
"prompt": "Custom extraction prompt.",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is False
assert kwargs["memory_type"] == "core"
assert kwargs["prompt"] == "Custom extraction prompt."
# ===========================================================================
# Edge cases: falsy-but-valid values must not be filtered out
# ===========================================================================
class TestFalsyValues:
"""The handler filters with `v is not None`. Falsy values like False, 0,
0.0, and empty string must still be forwarded."""
def test_infer_false_not_filtered(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": False,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is False
def test_threshold_zero_not_filtered(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "threshold": 0.0,
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["threshold"] == 0.0
# ===========================================================================
# Extra/unknown fields are still silently ignored (existing Pydantic behavior)
# ===========================================================================
class TestUnknownFieldsIgnored:
def test_unknown_search_field_ignored(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "bogus_field": "xyz",
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "bogus_field" not in kwargs
def test_unknown_add_field_ignored(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"unknown_param": 42,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "unknown_param" not in kwargs
# ===========================================================================
# Backward compatibility: existing params still work
# ===========================================================================
class TestExistingParamsUnchanged:
def test_search_filters_still_forwarded(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food",
"user_id": "u1",
"agent_id": "a1",
"filters": {"category": "food"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["user_id"] == "u1"
assert kwargs["agent_id"] == "a1"
assert kwargs["filters"] == {"category": "food"}
def test_add_metadata_still_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"agent_id": "a1",
"metadata": {"source": "test"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["user_id"] == "u1"
assert kwargs["agent_id"] == "a1"
assert kwargs["metadata"] == {"source": "test"}
# ===========================================================================
# OpenAPI schema: new fields are documented
# ===========================================================================
class TestOpenAPISchema:
"""Verify the new fields appear in the auto-generated OpenAPI schema."""
def test_search_schema_includes_limit(self, client):
schema = client.get("/openapi.json").json()
search_props = schema["components"]["schemas"]["SearchRequest"]["properties"]
assert "limit" in search_props
assert search_props["limit"]["description"] == "Maximum number of results to return."
def test_search_schema_includes_threshold(self, client):
schema = client.get("/openapi.json").json()
search_props = schema["components"]["schemas"]["SearchRequest"]["properties"]
assert "threshold" in search_props
def test_add_schema_includes_infer(self, client):
schema = client.get("/openapi.json").json()
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
assert "infer" in add_props
def test_add_schema_includes_memory_type(self, client):
schema = client.get("/openapi.json").json()
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
assert "memory_type" in add_props
def test_add_schema_includes_prompt(self, client):
schema = client.get("/openapi.json").json()
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
assert "prompt" in add_props
# ===========================================================================
# Pydantic type validation: invalid types return 422
# ===========================================================================
class TestTypeValidation:
"""Verify FastAPI/Pydantic rejects invalid types with 422."""
def test_limit_string_rejected(self, client):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "limit": "not_a_number",
})
assert resp.status_code == 422
def test_threshold_string_rejected(self, client):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "threshold": "high",
})
assert resp.status_code == 422
def test_infer_string_coerced_by_pydantic(self, client, mock_memory):
"""Pydantic v2 coerces truthy strings like 'yes' to True for bool fields."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": "yes",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is True
def test_infer_invalid_value_rejected(self, client):
"""A value that cannot be coerced to bool should be rejected."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": [1, 2, 3],
})
assert resp.status_code == 422
def test_limit_float_rejected(self, client):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "limit": 5.7,
})
assert resp.status_code == 422
def test_memory_type_int_rejected(self, client):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"memory_type": 123,
})
assert resp.status_code == 422
# ===========================================================================
# Explicit null values: treated as omitted (filtered by `is not None`)
# ===========================================================================
class TestExplicitNull:
"""When a client sends null for an optional field, it should be treated
as omitted — the Memory class default should be used."""
def test_limit_null_uses_memory_default(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "limit": None,
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "limit" not in kwargs
def test_infer_null_uses_memory_default(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": None,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "infer" not in kwargs
def test_prompt_null_uses_memory_default(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"prompt": None,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "prompt" not in kwargs
# ===========================================================================
# Verify exact call signatures match Memory method params
# ===========================================================================
class TestCallSignatureMatch:
"""Ensure forwarded params exactly match Memory.add() and Memory.search()
keyword argument names — a typo here would cause a TypeError at runtime."""
def test_search_kwargs_are_valid(self, client, mock_memory):
"""All kwargs forwarded to Memory.search() must be in its signature."""
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "agent_id": "a1",
"run_id": "r1", "filters": {"k": "v"},
"limit": 10, "threshold": 0.5,
})
assert resp.status_code == 200
# The handler passes query= as a keyword arg, so it appears in kwargs too
_, kwargs = mock_memory.search.call_args
valid_params = {"query", "user_id", "agent_id", "run_id", "limit", "filters", "threshold", "rerank"}
for key in kwargs:
assert key in valid_params, f"Unexpected kwarg '{key}' forwarded to Memory.search()"
def test_add_kwargs_are_valid(self, client, mock_memory):
"""All kwargs forwarded to Memory.add() must be in its signature."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hi"}],
"user_id": "u1", "agent_id": "a1", "run_id": "r1",
"metadata": {"k": "v"},
"infer": False, "memory_type": "core", "prompt": "custom",
})
assert resp.status_code == 200
# The handler passes messages= as a keyword arg, so it appears in kwargs too
_, kwargs = mock_memory.add.call_args
valid_params = {"messages", "user_id", "agent_id", "run_id", "metadata", "infer", "memory_type", "prompt"}
for key in kwargs:
assert key in valid_params, f"Unexpected kwarg '{key}' forwarded to Memory.add()"
def test_messages_excluded_from_params_dict(self, client, mock_memory):
"""messages is passed separately via messages= kwarg, not duplicated from model_dump."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hi"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
# messages should be present (passed explicitly) and be a list of dicts
assert "messages" in kwargs
assert isinstance(kwargs["messages"], list)
assert kwargs["messages"][0] == {"role": "user", "content": "hi"}
def test_query_passed_explicitly(self, client, mock_memory):
"""query is passed as an explicit keyword arg to Memory.search()."""
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["query"] == "food"
# ===========================================================================
# MemoryUpdate: text and metadata forwarding (fix for #3933)
# ===========================================================================
class TestUpdateMemory:
"""Verify that PUT /memories/{id} extracts text and metadata from the
request body and forwards them correctly to Memory.update()."""
def test_text_forwarded_as_data(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={"text": "Likes tennis"})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert kwargs["data"] == "Likes tennis"
def test_metadata_forwarded(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={
"text": "Likes tennis",
"metadata": {"category": "sports"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert kwargs["metadata"] == {"category": "sports"}
def test_metadata_omitted_passes_none(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={"text": "Likes tennis"})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert kwargs["metadata"] is None
def test_missing_text_returns_422(self, client):
"""text is required — omitting it should fail validation."""
resp = client.put("/memories/mem-1", json={"metadata": {"k": "v"}})
assert resp.status_code == 422
def test_dict_not_passed_as_data(self, client, mock_memory):
"""Regression test for #3933: the entire dict must NOT be passed as data."""
resp = client.put("/memories/mem-1", json={"text": "updated content"})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert isinstance(kwargs["data"], str)
class TestUpdateOpenAPISchema:
"""Verify the MemoryUpdate schema appears in the OpenAPI docs."""
def test_update_schema_includes_text(self, client):
schema = client.get("/openapi.json").json()
update_props = schema["components"]["schemas"]["MemoryUpdate"]["properties"]
assert "text" in update_props
def test_update_schema_includes_metadata(self, client):
schema = client.get("/openapi.json").json()
update_props = schema["components"]["schemas"]["MemoryUpdate"]["properties"]
assert "metadata" in update_props
+304 -8
View File
@@ -1,3 +1,4 @@
import asyncio
import threading
from unittest.mock import MagicMock, patch
@@ -19,11 +20,16 @@ class TestTelemetryDisabled:
assert at.user_id is None
def test_capture_event_noop_when_disabled(self):
"""capture_event() should return immediately without creating AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
"""capture_event() should return immediately without touching the singleton."""
saved = telemetry_module._oss_telemetry_instance
try:
telemetry_module._oss_telemetry_instance = None
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
telemetry_module.capture_event("test.event", MagicMock())
mock_cls.assert_not_called()
# Singleton must not have been initialised
assert telemetry_module._oss_telemetry_instance is None
finally:
telemetry_module._oss_telemetry_instance = saved
def test_capture_client_event_noop_when_disabled(self):
"""capture_client_event() should return immediately without calling posthog."""
@@ -69,11 +75,10 @@ class TestTelemetryEnabled:
assert at.user_id == "test-user"
def test_capture_event_sends_when_enabled(self):
"""capture_event() should create AnonymousTelemetry and call capture when enabled."""
"""capture_event() should use the singleton and call capture when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
mock_at = MagicMock()
mock_cls.return_value = mock_at
mock_at = MagicMock()
with patch.object(telemetry_module, "_oss_telemetry_instance", mock_at):
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
@@ -91,6 +96,297 @@ class TestTelemetryEnabled:
mock_client_telemetry.capture_event.assert_called_once()
class TestAnonymousTelemetryClose:
"""Verify AnonymousTelemetry.close() edge cases."""
def test_close_calls_posthog_shutdown(self):
"""close() should call posthog.shutdown()."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
mock_ph = mock_posthog_cls.return_value
at.close()
mock_ph.shutdown.assert_called_once()
def test_close_sets_posthog_to_none(self):
"""close() should set posthog to None to prevent double-shutdown."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
at.close()
assert at.posthog is None
def test_double_close_is_safe(self):
"""Calling close() twice should not raise."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
mock_ph = mock_posthog_cls.return_value
at.close()
at.close() # should not raise
# shutdown only called once because posthog was set to None after first close
mock_ph.shutdown.assert_called_once()
def test_capture_after_close_is_noop(self):
"""capture_event() should be a no-op after close() (posthog is None)."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
mock_ph = mock_posthog_cls.return_value
at.close()
mock_ph.reset_mock()
at.capture_event("test.event", {"key": "value"})
mock_ph.capture.assert_not_called()
class TestTelemetrySingleton:
"""Verify the OSS telemetry singleton behaviour."""
def setup_method(self):
# Reset singleton state before each test
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def teardown_method(self):
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def test_singleton_reuses_instance(self):
"""_get_oss_telemetry() should return the same instance on repeated calls."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
first = telemetry_module._get_oss_telemetry()
second = telemetry_module._get_oss_telemetry()
assert first is second
def test_singleton_created_only_once_across_threads(self):
"""Only one AnonymousTelemetry should be created even under concurrent access."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
instances = []
def grab():
instances.append(telemetry_module._get_oss_telemetry())
threads = [threading.Thread(target=grab) for _ in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(set(id(i) for i in instances)) == 1
assert mock_posthog.call_count == 1
def test_atexit_registered_once(self):
"""atexit.register should be called exactly once for the singleton."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register") as mock_atexit:
telemetry_module._get_oss_telemetry()
telemetry_module._get_oss_telemetry()
telemetry_module._get_oss_telemetry()
# Only one atexit registration despite multiple calls
mock_atexit.assert_called_once_with(telemetry_module._shutdown_oss_telemetry)
def test_capture_event_does_not_create_new_instance_each_call(self):
"""capture_event() should not create a new AnonymousTelemetry per call."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
telemetry_module.capture_event("e1", mock_memory)
first = telemetry_module._oss_telemetry_instance
telemetry_module.capture_event("e2", mock_memory)
second = telemetry_module._oss_telemetry_instance
assert first is second
def test_posthog_constructed_once_across_many_capture_event_calls(self):
"""The core leak fix: Posthog() should only be called once no matter how
many times capture_event() is invoked."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
for i in range(50):
telemetry_module.capture_event(f"event_{i}", mock_memory)
# Only ONE Posthog client created, not 50
assert mock_posthog_cls.call_count == 1
def test_shutdown_clears_singleton(self):
"""_shutdown_oss_telemetry() should close and clear the singleton."""
mock_at = MagicMock()
telemetry_module._oss_telemetry_instance = mock_at
telemetry_module._shutdown_oss_telemetry()
mock_at.close.assert_called_once()
assert telemetry_module._oss_telemetry_instance is None
def test_shutdown_idempotent(self):
"""Calling _shutdown_oss_telemetry() twice should not raise."""
mock_at = MagicMock()
telemetry_module._oss_telemetry_instance = mock_at
telemetry_module._shutdown_oss_telemetry()
telemetry_module._shutdown_oss_telemetry() # should not raise
mock_at.close.assert_called_once()
def test_shutdown_noop_when_no_instance(self):
"""_shutdown_oss_telemetry() should be a no-op when singleton was never created."""
assert telemetry_module._oss_telemetry_instance is None
telemetry_module._shutdown_oss_telemetry() # should not raise
def test_capture_event_noop_when_disabled_with_singleton(self):
"""capture_event() should not initialise the singleton when telemetry is off."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
telemetry_module.capture_event("test.event", MagicMock())
assert telemetry_module._oss_telemetry_instance is None
def test_no_new_instance_after_shutdown(self):
"""After _shutdown_oss_telemetry(), _get_oss_telemetry() should not create a new instance."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
# Create and then shut down the singleton
telemetry_module._get_oss_telemetry()
telemetry_module._shutdown_oss_telemetry()
# After shutdown, getting telemetry should return None, not a new instance
result = telemetry_module._get_oss_telemetry()
assert result is None
assert telemetry_module._oss_telemetry_instance is None
class TestMemoryLifecycle:
"""Verify Memory.close() and context manager support."""
def _make_mock_memory(self):
"""Create a Memory-like object with a mock db for testing close()."""
from mem0.memory.main import Memory
with patch.object(Memory, "__init__", lambda self: None):
m = Memory.__new__(Memory)
m.db = MagicMock()
return m
def test_close_calls_db_close(self):
m = self._make_mock_memory()
m.close()
m.db.close.assert_called_once()
def test_double_close_is_safe(self):
"""close() should be safe to call twice (SQLiteManager.close sets connection=None)."""
m = self._make_mock_memory()
m.close()
# After first close, db is still the mock but SQLiteManager.close() handles
# the None-connection case internally. Simulate that by making db.close a no-op.
m.close() # should not raise
def test_close_when_db_is_none(self):
"""close() should not raise if db was already None."""
m = self._make_mock_memory()
m.db = None
m.close() # should not raise
def test_close_when_db_not_set(self):
"""close() should not raise if __init__ failed before setting db."""
from mem0.memory.main import Memory
with patch.object(Memory, "__init__", lambda self: None):
m = Memory.__new__(Memory)
# db attribute not set at all
m.close() # should not raise due to hasattr guard
def test_context_manager(self):
"""Memory should support `with` statement and close on exit."""
m = self._make_mock_memory()
with m:
pass
m.db.close.assert_called_once()
def test_context_manager_closes_on_exception(self):
"""Memory should close even if the with-block raises."""
m = self._make_mock_memory()
with pytest.raises(ValueError):
with m:
raise ValueError("boom")
m.db.close.assert_called_once()
class TestAsyncMemoryLifecycle:
"""Verify AsyncMemory.close() and async context manager support."""
def _make_mock_async_memory(self):
from mem0.memory.main import AsyncMemory
with patch.object(AsyncMemory, "__init__", lambda self: None):
m = AsyncMemory.__new__(AsyncMemory)
m.db = MagicMock()
return m
def test_close_calls_db_close(self):
m = self._make_mock_async_memory()
m.close()
m.db.close.assert_called_once()
def test_close_when_db_is_none(self):
m = self._make_mock_async_memory()
m.db = None
m.close() # should not raise
def test_close_when_db_not_set(self):
from mem0.memory.main import AsyncMemory
with patch.object(AsyncMemory, "__init__", lambda self: None):
m = AsyncMemory.__new__(AsyncMemory)
m.close() # should not raise
def test_async_context_manager(self):
"""AsyncMemory should support `async with` and close on exit."""
m = self._make_mock_async_memory()
async def run():
async with m:
pass
asyncio.run(run())
m.db.close.assert_called_once()
def test_async_context_manager_closes_on_exception(self):
"""AsyncMemory should close even if the async with-block raises."""
m = self._make_mock_async_memory()
async def run():
async with m:
raise ValueError("boom")
with pytest.raises(ValueError):
asyncio.run(run())
m.db.close.assert_called_once()
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""
+206
View File
@@ -0,0 +1,206 @@
"""Tests for OSS telemetry sampling.
Tests target _sampling_before_send and its wiring directly. Existing tests in
test_telemetry.py cover opt-out, singleton, and lifecycle behavior and are
unaffected by sampling.
"""
from unittest.mock import patch
import pytest
import mem0.memory.telemetry as telemetry_module
class TestParseSampleRate:
"""Verify the env var parsing helper. Must never raise."""
@pytest.mark.parametrize(
"raw,expected",
[
("0.0", 0.0),
("0.1", 0.1),
("0.5", 0.5),
("1.0", 1.0),
("0.05", 0.05),
],
)
def test_valid_values(self, raw, expected):
assert telemetry_module._parse_sample_rate(raw) == expected
@pytest.mark.parametrize("raw", ["abc", "", "5.0", "-1", "-0.5", "1.5", "2", "nan"])
def test_invalid_values_fall_back_to_default(self, raw):
assert telemetry_module._parse_sample_rate(raw) == telemetry_module._DEFAULT_SAMPLE_RATE
def test_none_falls_back(self):
assert telemetry_module._parse_sample_rate(None) == telemetry_module._DEFAULT_SAMPLE_RATE
class TestSamplingBeforeSend:
"""Verify _sampling_before_send: the PostHog hook that drops/keeps events."""
def _msg(self, event_name, properties=None):
return {"event": event_name, "properties": properties or {}}
def test_lifecycle_event_passes_through_at_high_random(self):
"""Lifecycle events always fire even when random is near 1.0."""
with patch("mem0.memory.telemetry.random.random", return_value=0.999):
for event in ("mem0.init", "mem0.reset", "mem0._create_procedural_memory"):
result = telemetry_module._sampling_before_send(self._msg(event))
assert result is not None
assert result["properties"]["sample_rate"] == 1.0
def test_hot_path_event_dropped_when_random_above_rate(self):
"""At default rate 0.1, random=0.99 should drop the event."""
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 0.1):
with patch("mem0.memory.telemetry.random.random", return_value=0.99):
assert telemetry_module._sampling_before_send(self._msg("mem0.add")) is None
def test_hot_path_event_passes_when_random_below_rate(self):
"""At default rate 0.1, random=0.05 should pass the event through."""
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 0.1):
with patch("mem0.memory.telemetry.random.random", return_value=0.05):
result = telemetry_module._sampling_before_send(self._msg("mem0.add"))
assert result is not None
assert result["properties"]["sample_rate"] == 0.1
def test_hot_path_annotates_sample_rate(self):
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 0.1):
with patch("mem0.memory.telemetry.random.random", return_value=0.0):
result = telemetry_module._sampling_before_send(self._msg("mem0.search"))
assert result["properties"]["sample_rate"] == 0.1
def test_lifecycle_annotates_sample_rate_one(self):
result = telemetry_module._sampling_before_send(self._msg("mem0.init"))
assert result["properties"]["sample_rate"] == 1.0
def test_rate_zero_drops_all_hot_path(self):
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 0.0):
# Gate is `random >= rate`, so 0.0 >= 0.0 → drop. random ∈ [0, 1) means
# rate=0 drops every event.
with patch("mem0.memory.telemetry.random.random", return_value=0.0):
assert telemetry_module._sampling_before_send(self._msg("mem0.add")) is None
def test_rate_zero_still_passes_lifecycle(self):
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 0.0):
with patch("mem0.memory.telemetry.random.random", return_value=0.999):
assert telemetry_module._sampling_before_send(self._msg("mem0.init")) is not None
def test_rate_one_passes_all_events(self):
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 1.0):
with patch("mem0.memory.telemetry.random.random", return_value=0.999):
# 0.999 > 1.0 is False, so the gate never trips
assert telemetry_module._sampling_before_send(self._msg("mem0.add")) is not None
def test_does_not_override_caller_supplied_sample_rate_for_hot_path(self):
"""If a caller pre-populates sample_rate, our value still wins (we're authoritative)."""
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 0.1):
with patch("mem0.memory.telemetry.random.random", return_value=0.0):
msg = self._msg("mem0.add", {"sample_rate": 0.99, "other": "x"})
result = telemetry_module._sampling_before_send(msg)
assert result["properties"]["sample_rate"] == 0.1
assert result["properties"]["other"] == "x"
def test_handles_missing_properties_dict(self):
"""If a msg arrives without a properties key, we create it before annotating."""
msg = {"event": "mem0.init"} # no properties
result = telemetry_module._sampling_before_send(msg)
assert result is not None
assert result["properties"]["sample_rate"] == 1.0
def test_handles_missing_event_field(self):
"""Defensive: a msg with no event field is treated as a hot-path event (gets sampled)."""
with patch.object(telemetry_module, "MEM0_TELEMETRY_SAMPLE_RATE", 0.1):
with patch("mem0.memory.telemetry.random.random", return_value=0.99):
# event is missing -> treated as hot-path -> dropped at high random
assert telemetry_module._sampling_before_send({"properties": {}}) is None
def test_handles_non_dict_input(self):
"""Defensive: if PostHog ever passes us a non-dict, we drop the event."""
assert telemetry_module._sampling_before_send("not a dict") is None
assert telemetry_module._sampling_before_send(None) is None
assert telemetry_module._sampling_before_send(42) is None
assert telemetry_module._sampling_before_send(["list"]) is None
class TestBeforeSendWiring:
"""Verify the hook is wired into the OSS singleton but NOT client_telemetry."""
def setup_method(self):
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def teardown_method(self):
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def test_oss_singleton_constructed_with_before_send_hook(self):
"""_get_oss_telemetry() should pass _sampling_before_send to AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
telemetry_module._get_oss_telemetry()
# Verify Posthog() was constructed with before_send=_sampling_before_send
_, kwargs = mock_posthog_cls.call_args
assert kwargs.get("before_send") is telemetry_module._sampling_before_send
def test_client_telemetry_constructed_without_before_send(self):
"""The module-level client_telemetry must NOT receive before_send.
client_telemetry is created at import time, so this test verifies the
existing instance has no sampling hook attached to its underlying Posthog.
"""
# client_telemetry is created at module import time. We verify the
# construction call site by re-constructing AnonymousTelemetry() the
# same way and checking the kwargs.
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
telemetry_module.AnonymousTelemetry() # mirrors client_telemetry construction
_, kwargs = mock_posthog_cls.call_args
# before_send is None (default), not _sampling_before_send
assert kwargs.get("before_send") is None
def test_anonymous_telemetry_falls_back_when_posthog_rejects_before_send(self):
"""If posthog (older version) rejects before_send, construction still succeeds."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
# First call (with before_send) raises TypeError; second call succeeds.
mock_posthog_cls.side_effect = [TypeError("unexpected kwarg before_send"), object()]
at = telemetry_module.AnonymousTelemetry(before_send=telemetry_module._sampling_before_send)
# Constructor was called twice: once with before_send, once without
assert mock_posthog_cls.call_count == 2
assert at.posthog is not None
class TestSamplingDoesNotBreakExistingBehavior:
"""Regression checks: sampling must be invisible above the SDK boundary."""
def setup_method(self):
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def teardown_method(self):
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def test_capture_event_signature_unchanged(self):
"""The public capture_event() function still accepts the same args."""
from inspect import signature
params = list(signature(telemetry_module.capture_event).parameters)
assert params == ["event_name", "memory_instance", "additional_data"]
def test_capture_client_event_signature_unchanged(self):
from inspect import signature
params = list(signature(telemetry_module.capture_client_event).parameters)
assert params == ["event_name", "instance", "additional_data"]
def test_default_sample_rate_is_ten_percent(self):
"""Module loads with the documented default."""
# We can't assert MEM0_TELEMETRY_SAMPLE_RATE directly because the user
# may have set the env var, so assert the default constant instead.
assert telemetry_module._DEFAULT_SAMPLE_RATE == 0.1
+1 -1
View File
@@ -533,7 +533,7 @@ def test_search_basic(azure_ai_search_instance):
# Search with a vector
query_text = "test query" # Add a query string
query_vector = [0.1, 0.2, 0.3]
results = instance.search(query_text, query_vector, limit=5) # Pass the query string
results = instance.search(query_text, query_vector, top_k=5) # Pass the query string
# Verify search was called correctly
mock_search_client.search.assert_called_once()
+4 -3
View File
@@ -1,7 +1,8 @@
import json
import pytest
from unittest.mock import Mock, patch
import pytest
from mem0.vector_stores.azure_mysql import AzureMySQL, OutputData
@@ -118,7 +119,7 @@ def test_search(azure_mysql_instance):
])
query_vector = [0.2, 0.3, 0.4]
results = azure_mysql_instance.search(query="test", vectors=query_vector, limit=5)
results = azure_mysql_instance.search(query="test", vectors=query_vector, top_k=5)
assert isinstance(results, list)
assert cursor.execute.called
@@ -218,7 +219,7 @@ def test_list(azure_mysql_instance):
}
])
results = azure_mysql_instance.list(limit=10)
results = azure_mysql_instance.list(top_k=10)
assert isinstance(results, list)
assert len(results) > 0
+3 -3
View File
@@ -112,7 +112,7 @@ def test_search(mochow_instance, mock_mochow_client):
mochow_instance._table.vector_search.return_value = mock_search_results
vectors = [0.1, 0.2, 0.3]
results = mochow_instance.search(query="test", vectors=vectors, limit=2)
results = mochow_instance.search(query="test", vectors=vectors, top_k=2)
# Verify search was called with correct parameters
mochow_instance._table.vector_search.assert_called_once()
@@ -143,7 +143,7 @@ def test_search_with_filters(mochow_instance, mock_mochow_client):
vectors = [0.1, 0.2, 0.3]
filters = {"user_id": "user123", "agent_id": "agent456"}
mochow_instance.search(query="test", vectors=vectors, limit=2, filters=filters)
mochow_instance.search(query="test", vectors=vectors, top_k=2, filters=filters)
# Verify search was called with filter
call_args = mochow_instance._table.vector_search.call_args
@@ -196,7 +196,7 @@ def test_list(mochow_instance, mock_mochow_client):
mock_result.rows = [{"id": "id1", "metadata": {"name": "vector1"}}, {"id": "id2", "metadata": {"name": "vector2"}}]
mochow_instance._table.select.return_value = mock_result
results = mochow_instance.list(limit=2)
results = mochow_instance.list(top_k=2)
mochow_instance._table.select.assert_called_once_with(filter=None, projections=["id", "metadata"], limit=2)
+5 -4
View File
@@ -1,7 +1,8 @@
import json
import pytest
from unittest.mock import Mock, patch
import pytest
from mem0.vector_stores.cassandra import CassandraDB, OutputData
@@ -106,7 +107,7 @@ def test_search(cassandra_instance):
cassandra_instance.session.execute = Mock(return_value=[mock_row1, mock_row2])
query_vector = [0.2, 0.3, 0.4]
results = cassandra_instance.search(query="test", vectors=query_vector, limit=5)
results = cassandra_instance.search(query="test", vectors=query_vector, top_k=5)
assert isinstance(results, list)
assert len(results) <= 5
@@ -213,7 +214,7 @@ def test_list(cassandra_instance):
cassandra_instance.session.execute = Mock(return_value=[mock_row])
results = cassandra_instance.list(limit=10)
results = cassandra_instance.list(top_k=10)
assert isinstance(results, list)
assert len(results) > 0
@@ -264,7 +265,7 @@ def test_search_with_filters(cassandra_instance):
results = cassandra_instance.search(
query="test",
vectors=query_vector,
limit=5,
top_k=5,
filters={"category": "A"}
)
+20 -7
View File
@@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
import pytest
from mem0.configs.vector_stores.chroma import ChromaDbConfig
from mem0.vector_stores.chroma import ChromaDB
@@ -38,7 +39,7 @@ def test_search_vectors(chromadb_instance, mock_chromadb_client):
chromadb_instance.collection.query.return_value = mock_result
vectors = [[0.1, 0.2, 0.3]]
results = chromadb_instance.search(query="", vectors=vectors, limit=2)
results = chromadb_instance.search(query="", vectors=vectors, top_k=2)
chromadb_instance.collection.query.assert_called_once_with(query_embeddings=vectors, where=None, n_results=2)
@@ -59,7 +60,7 @@ def test_search_vectors_with_filters(chromadb_instance, mock_chromadb_client):
vectors = [[0.1, 0.2, 0.3]]
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = chromadb_instance.search(query="", vectors=vectors, limit=2, filters=filters)
results = chromadb_instance.search(query="", vectors=vectors, top_k=2, filters=filters)
# Verify that _generate_where_clause was called with the filters
expected_where = {"$and": [{"user_id": {"$eq": "alice"}}, {"agent_id": {"$eq": "agent1"}}, {"run_id": {"$eq": "run1"}}]}
@@ -85,7 +86,7 @@ def test_search_vectors_with_single_filter(chromadb_instance, mock_chromadb_clie
vectors = [[0.1, 0.2, 0.3]]
filters = {"user_id": "alice"}
results = chromadb_instance.search(query="", vectors=vectors, limit=2, filters=filters)
results = chromadb_instance.search(query="", vectors=vectors, top_k=2, filters=filters)
# Verify that single filter is passed with $eq operator
expected_where = {"user_id": {"$eq": "alice"}}
@@ -107,7 +108,7 @@ def test_search_vectors_with_no_filters(chromadb_instance, mock_chromadb_client)
chromadb_instance.collection.query.return_value = mock_result
vectors = [[0.1, 0.2, 0.3]]
results = chromadb_instance.search(query="", vectors=vectors, limit=2, filters=None)
results = chromadb_instance.search(query="", vectors=vectors, top_k=2, filters=None)
chromadb_instance.collection.query.assert_called_once_with(
query_embeddings=vectors, where=None, n_results=2
@@ -161,7 +162,7 @@ def test_list_vectors(chromadb_instance):
}
chromadb_instance.collection.get.return_value = mock_result
results = chromadb_instance.list(limit=2)
results = chromadb_instance.list(top_k=2)
chromadb_instance.collection.get.assert_called_once_with(where=None, limit=2)
@@ -180,7 +181,7 @@ def test_list_vectors_with_filters(chromadb_instance):
chromadb_instance.collection.get.return_value = mock_result
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = chromadb_instance.list(filters=filters, limit=2)
results = chromadb_instance.list(filters=filters, top_k=2)
# Verify that _generate_where_clause was called with the filters
expected_where = {"$and": [{"user_id": {"$eq": "alice"}}, {"agent_id": {"$eq": "agent1"}}, {"run_id": {"$eq": "run1"}}]}
@@ -202,7 +203,7 @@ def test_list_vectors_with_single_filter(chromadb_instance):
chromadb_instance.collection.get.return_value = mock_result
filters = {"user_id": "alice"}
results = chromadb_instance.list(filters=filters, limit=2)
results = chromadb_instance.list(filters=filters, top_k=2)
# Verify that single filter is passed with $eq operator
expected_where = {"user_id": {"$eq": "alice"}}
@@ -249,3 +250,15 @@ def test_generate_where_clause_non_string_values():
# ChromaDB accepts non-string values in filters
expected = {"$and": [{"user_id": {"$eq": "alice"}}, {"count": {"$eq": 5}}, {"active": {"$eq": True}}]}
assert result == expected
def test_chroma_config_accepts_default_tmp_path():
"""Test that ChromaDbConfig accepts the default /tmp/chroma path."""
config = ChromaDbConfig(path="/tmp/chroma")
assert config.path == "/tmp/chroma"
def test_chroma_config_rejects_no_config():
"""Test that ChromaDbConfig rejects when no connection config is provided."""
with pytest.raises(ValueError):
ChromaDbConfig()
+736 -12
View File
@@ -1,9 +1,19 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from databricks.sdk.service.vectorsearch import VectorIndexType, QueryVectorIndexResponse, ResultManifest, ResultData, ColumnInfo
from mem0.vector_stores.databricks import Databricks
import pytest
pytest.importorskip("databricks", reason="databricks-sdk package not installed")
from databricks.sdk.service.vectorsearch import (
ColumnInfo,
QueryVectorIndexResponse,
ResultData,
ResultManifest,
VectorIndexType,
)
from mem0.vector_stores.databricks import Databricks
# ---------------------- Fixtures ---------------------- #
@@ -149,9 +159,16 @@ def test_insert_generates_sql(db_instance_direct, mock_workspace_client):
sql = kwargs["statement"] if "statement" in kwargs else args[0]
assert "INSERT INTO" in sql
assert "catalog.schema.table" in sql
assert "id1" in sql
# Embedding list rendered
# Embedding list rendered inline (ARRAY type not supported by parameterized queries)
assert "array(0.1, 0.2, 0.3, 0.4)" in sql
# User-supplied values should use parameterized queries, not inline values
assert ":memory_id_0" in sql
assert "id1" not in sql # id should be in params, not in SQL
params = kwargs["parameters"]
param_names = {p.name for p in params}
assert "memory_id_0" in param_names
id_param = next(p for p in params if p.name == "memory_id_0")
assert id_param.value == "id1"
# ---------------------- Search Tests ---------------------- #
@@ -174,7 +191,7 @@ def test_search_delta_sync_text(db_instance_delta, mock_workspace_client):
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(data_array=[row])
)
results = db_instance_delta.search(query="hello", vectors=None, limit=1)
results = db_instance_delta.search(query="hello", vectors=None, top_k=1)
mock_workspace_client.vector_search_indexes.query_index.assert_called_once()
assert len(results) == 1
assert results[0].id == "id1"
@@ -199,15 +216,40 @@ def test_search_direct_access_vector(db_instance_direct, mock_workspace_client):
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(data_array=[row])
)
results = db_instance_direct.search(query="", vectors=[0.1, 0.2, 0.3, 0.4], limit=1)
results = db_instance_direct.search(query="", vectors=[0.1, 0.2, 0.3, 0.4], top_k=1)
assert len(results) == 1
assert results[0].id == "id2"
assert results[0].score == 0.77
def test_search_delta_sync_self_managed_vectors(mock_workspace_client):
"""DELTA_SYNC without embedding model endpoint should use query_vector, not query_text."""
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
# DELTA_SYNC without embedding_model_endpoint_name = self-managed vectors
inst = Databricks(
workspace_url="https://test",
access_token="tok",
endpoint_name="vs-endpoint",
catalog="catalog",
schema="schema",
table_name="table",
warehouse_name="test-warehouse",
index_type=VectorIndexType.DELTA_SYNC,
embedding_dimension=4,
# NOTE: no embedding_model_endpoint_name
)
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(data_array=[])
)
inst.search(query="ignored", vectors=[0.1, 0.2, 0.3, 0.4], top_k=5)
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in call_kwargs
assert "query_text" not in call_kwargs
def test_search_missing_params_raises(db_instance_delta):
with pytest.raises(ValueError):
db_instance_delta.search(query="", vectors=[0.1, 0.2]) # DELTA_SYNC requires query text
db_instance_delta.search(query="", vectors=[0.1, 0.2]) # DELTA_SYNC with model endpoint requires query text
# ---------------------- Delete Tests ---------------------- #
@@ -217,7 +259,13 @@ def test_delete_vector(db_instance_delta, mock_workspace_client):
db_instance_delta.delete("id-delete")
args, kwargs = mock_workspace_client.statement_execution.execute_statement.call_args
sql = kwargs.get("statement") or args[0]
assert "DELETE FROM" in sql and "id-delete" in sql
assert "DELETE FROM" in sql
assert ":vector_id" in sql
assert "id-delete" not in sql # value should be in params, not in SQL
params = kwargs["parameters"]
assert len(params) == 1
assert params[0].name == "vector_id"
assert params[0].value == "id-delete"
# ---------------------- Update Tests ---------------------- #
@@ -231,10 +279,17 @@ def test_update_vector(db_instance_direct, mock_workspace_client):
)
args, kwargs = mock_workspace_client.statement_execution.execute_statement.call_args
sql = kwargs.get("statement") or args[0]
assert "UPDATE" in sql and "id-upd" in sql
assert "embedding = [0.4, 0.5, 0.6, 0.7]" in sql
assert "custom = 'val'" in sql
assert "UPDATE" in sql
assert "embedding = array(0.4, 0.5, 0.6, 0.7)" in sql
assert "custom = :payload_custom" in sql
assert ":vector_id" in sql
assert "id-upd" not in sql # value should be in params, not in SQL
assert "'val'" not in sql # value should be in params, not in SQL
assert "user_id" not in sql # excluded
params = kwargs["parameters"]
param_map = {p.name: p.value for p in params}
assert param_map["payload_custom"] == "val"
assert param_map["vector_id"] == "id-upd"
# ---------------------- Get Tests ---------------------- #
@@ -275,6 +330,54 @@ def test_get_vector(db_instance_delta, mock_workspace_client):
assert res.id == "id-get"
assert res.payload["data"] == "some memory"
assert res.payload["tag"] == "x"
# DELTA_SYNC should use query_text, not query_vector
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_text" in call_kwargs
assert "query_vector" not in call_kwargs
def test_get_vector_direct_access(db_instance_direct, mock_workspace_client):
"""get() on a DIRECT_ACCESS index must use query_vector instead of query_text."""
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
manifest=ResultManifest(columns=[
ColumnInfo(name="memory_id"),
ColumnInfo(name="hash"),
ColumnInfo(name="agent_id"),
ColumnInfo(name="run_id"),
ColumnInfo(name="user_id"),
ColumnInfo(name="memory"),
ColumnInfo(name="metadata"),
ColumnInfo(name="created_at"),
ColumnInfo(name="updated_at"),
ColumnInfo(name="embedding"),
ColumnInfo(name="score"),
]),
result=ResultData(
data_array=[
[
"id-get-da",
"h",
"a",
"r",
"u",
"direct access memory",
'{"tag":"da"}',
"2024-01-01T00:00:00",
"2024-01-01T00:00:00",
[0.1, 0.2, 0.3, 0.4],
"0.88",
]
]
)
)
res = db_instance_direct.get("id-get-da")
assert res.id == "id-get-da"
assert res.payload["data"] == "direct access memory"
# DIRECT_ACCESS should use query_vector, not query_text
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in call_kwargs
assert "query_text" not in call_kwargs
assert call_kwargs["query_vector"] == [0.0] * 4 # embedding_dimension=4
# ---------------------- Collection Info / Listing Tests ---------------------- #
@@ -326,10 +429,189 @@ def test_list_memories(db_instance_delta, mock_workspace_client):
]
)
)
res = db_instance_delta.list(limit=1)
res = db_instance_delta.list(top_k=1)
assert isinstance(res, list)
assert len(res[0]) == 1
assert res[0][0].id == "id-get"
# DELTA_SYNC should use query_text
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_text" in call_kwargs
assert "query_vector" not in call_kwargs
def test_list_memories_direct_access(db_instance_direct, mock_workspace_client):
"""list() on a DIRECT_ACCESS index must use query_vector instead of query_text."""
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(
data_array=[
[
"id-da-list",
"h",
"a",
"r",
"u",
"direct memory",
None,
"2024-01-01T00:00:00",
"2024-01-01T00:00:00",
[0.1, 0.2, 0.3, 0.4],
]
]
)
)
res = db_instance_direct.list(top_k=5)
assert isinstance(res, list)
assert len(res[0]) == 1
assert res[0][0].id == "id-da-list"
assert res[0][0].payload["data"] == "direct memory"
# DIRECT_ACCESS should use query_vector
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in call_kwargs
assert "query_text" not in call_kwargs
assert call_kwargs["query_vector"] == [0.0] * 4
def test_get_vector_delta_sync_self_managed(mock_workspace_client):
"""get() on DELTA_SYNC without model endpoint should use query_vector."""
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
inst = Databricks(
workspace_url="https://test",
access_token="tok",
endpoint_name="vs-endpoint",
catalog="catalog",
schema="schema",
table_name="table",
warehouse_name="test-warehouse",
index_type=VectorIndexType.DELTA_SYNC,
embedding_dimension=4,
# NOTE: no embedding_model_endpoint_name
)
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
manifest=ResultManifest(columns=[
ColumnInfo(name="memory_id"), ColumnInfo(name="hash"),
ColumnInfo(name="agent_id"), ColumnInfo(name="run_id"),
ColumnInfo(name="user_id"), ColumnInfo(name="memory"),
ColumnInfo(name="metadata"), ColumnInfo(name="created_at"),
ColumnInfo(name="updated_at"),
]),
result=ResultData(data_array=[["id-sm", "h", None, None, None, "self-managed mem", None, None, None]]),
)
res = inst.get("id-sm")
assert res.id == "id-sm"
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in call_kwargs
assert "query_text" not in call_kwargs
assert call_kwargs["query_vector"] == [0.0] * 4
def test_list_memories_delta_sync_self_managed(mock_workspace_client):
"""list() on DELTA_SYNC without model endpoint should use query_vector."""
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
inst = Databricks(
workspace_url="https://test",
access_token="tok",
endpoint_name="vs-endpoint",
catalog="catalog",
schema="schema",
table_name="table",
warehouse_name="test-warehouse",
index_type=VectorIndexType.DELTA_SYNC,
embedding_dimension=4,
# NOTE: no embedding_model_endpoint_name
)
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(data_array=[])
)
inst.list(top_k=5)
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in call_kwargs
assert "query_text" not in call_kwargs
def test_list_memories_default_limit(db_instance_delta, mock_workspace_client):
"""list() with no limit should default to 100."""
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(data_array=[])
)
db_instance_delta.list(top_k=None)
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert call_kwargs["num_results"] == 100
# ---------------------- Table Creation Tests ---------------------- #
def test_ensure_source_table_uses_dynamic_names(mock_workspace_client):
"""Verify _ensure_source_table_exists uses self.fully_qualified_table_name and
self.table_name for the PK constraint, not hardcoded values."""
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=False)
Databricks(
workspace_url="https://test",
access_token="tok",
endpoint_name="vs-endpoint",
catalog="my_catalog",
schema="my_schema",
table_name="my_memories",
collection_name="my_index",
warehouse_name="test-warehouse",
index_type=VectorIndexType.DELTA_SYNC,
embedding_model_endpoint_name="embedding-endpoint",
)
# _ensure_source_table_exists was called during __init__ via create_col
constraint_call = mock_workspace_client.table_constraints.create.call_args
assert constraint_call.kwargs["full_name_arg"] == "my_catalog.my_schema.my_memories"
pk_name = constraint_call.kwargs["constraint"].primary_key_constraint.name
assert pk_name == "pk_my_memories"
# ---------------------- Config Validation Tests ---------------------- #
def test_config_rejects_old_doc_params():
"""Config should reject the old documentation parameter names like index_name and source_table_name."""
from mem0.configs.vector_stores.databricks import DatabricksConfig
with pytest.raises(ValueError, match="Extra fields not allowed"):
DatabricksConfig(
workspace_url="https://test",
access_token="tok",
endpoint_name="ep",
catalog="cat",
schema="sch",
table_name="tbl",
index_name="catalog.schema.index", # old param from docs
)
def test_config_rejects_source_table_name():
"""Config should reject source_table_name which was in old docs."""
from mem0.configs.vector_stores.databricks import DatabricksConfig
with pytest.raises(ValueError, match="Extra fields not allowed"):
DatabricksConfig(
workspace_url="https://test",
access_token="tok",
endpoint_name="ep",
catalog="cat",
schema="sch",
table_name="tbl",
source_table_name="catalog.schema.table", # old param from docs
)
def test_config_accepts_correct_params():
"""Config should accept all the correct parameter names."""
from mem0.configs.vector_stores.databricks import DatabricksConfig
config = DatabricksConfig(
workspace_url="https://test",
access_token="tok",
endpoint_name="ep",
catalog="cat",
schema="sch",
table_name="tbl",
collection_name="my_index",
embedding_dimension=768,
)
assert config.collection_name == "my_index"
assert config.embedding_dimension == 768
# ---------------------- Reset Tests ---------------------- #
@@ -341,3 +623,445 @@ def test_reset(db_instance_delta, mock_workspace_client):
with patch.object(db_instance_delta, "create_col", wraps=db_instance_delta.create_col) as create_spy:
db_instance_delta.reset()
assert create_spy.called
# ---------------------- End-to-End Config → Factory → CRUD Tests ---------------------- #
def test_e2e_config_to_factory_delta_sync(mock_workspace_client):
"""End-to-end: VectorStoreConfig validates docs-correct params, factory creates Databricks instance."""
from mem0.utils.factory import VectorStoreFactory
from mem0.vector_stores.configs import VectorStoreConfig
# Step 1: Config validation (simulates what Memory.from_config does)
vs_config = VectorStoreConfig(
provider="databricks",
config={
"workspace_url": "https://my-workspace.databricks.com",
"access_token": "my-token",
"endpoint_name": "my-endpoint",
"catalog": "prod_catalog",
"schema": "ai_schema",
"table_name": "memories_table",
"collection_name": "my_index",
"embedding_dimension": 768,
"warehouse_name": "test-warehouse",
},
)
assert vs_config.config.collection_name == "my_index"
assert vs_config.config.catalog == "prod_catalog"
# Step 2: Factory instantiation (same as MemoryBase.__init__)
instance = VectorStoreFactory.create("databricks", vs_config.config)
assert isinstance(instance, Databricks)
assert instance.fully_qualified_table_name == "prod_catalog.ai_schema.memories_table"
assert instance.fully_qualified_index_name == "prod_catalog.ai_schema.my_index"
assert instance.embedding_dimension == 768
def test_e2e_config_to_factory_direct_access(mock_workspace_client):
"""End-to-end: DIRECT_ACCESS via config → factory creates correct instance."""
from mem0.utils.factory import VectorStoreFactory
from mem0.vector_stores.configs import VectorStoreConfig
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
vs_config = VectorStoreConfig(
provider="databricks",
config={
"workspace_url": "https://my-workspace.databricks.com",
"access_token": "my-token",
"endpoint_name": "my-endpoint",
"catalog": "cat",
"schema": "sch",
"table_name": "tbl",
"index_type": "DIRECT_ACCESS",
"embedding_dimension": 4,
"warehouse_name": "test-warehouse",
},
)
instance = VectorStoreFactory.create("databricks", vs_config.config)
assert isinstance(instance, Databricks)
assert "embedding" in instance.column_names
def test_e2e_old_docs_config_rejected():
"""End-to-end: Config from old docs (with index_name, source_table_name) is rejected at validation."""
from mem0.vector_stores.configs import VectorStoreConfig
with pytest.raises(ValueError, match="Extra fields not allowed"):
VectorStoreConfig(
provider="databricks",
config={
"workspace_url": "https://my-workspace.databricks.com",
"access_token": "my-token",
"endpoint_name": "my-endpoint",
"index_name": "catalog.schema.index_name",
"source_table_name": "catalog.schema.source_table",
"embedding_dimension": 1536,
},
)
def test_e2e_crud_lifecycle_delta_sync(mock_workspace_client):
"""End-to-end CRUD lifecycle: insert → search → get → list → update → delete."""
from mem0.utils.factory import VectorStoreFactory
from mem0.vector_stores.configs import VectorStoreConfig
vs_config = VectorStoreConfig(
provider="databricks",
config={
"workspace_url": "https://test",
"access_token": "tok",
"endpoint_name": "ep",
"catalog": "cat",
"schema": "sch",
"table_name": "tbl",
"warehouse_name": "test-warehouse",
"embedding_model_endpoint_name": "emb-ep",
},
)
db = VectorStoreFactory.create("databricks", vs_config.config)
# INSERT
db.insert(
vectors=[[0.1, 0.2]],
payloads=[{"data": "test memory", "user_id": "u1", "hash": "h1"}],
ids=["mem-001"],
)
insert_call = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
insert_sql = insert_call["statement"]
assert "INSERT INTO cat.sch.tbl" in insert_sql
assert ":memory_id_0" in insert_sql # parameterized, not inline
insert_params = {p.name: p.value for p in insert_call["parameters"]}
assert insert_params["memory_id_0"] == "mem-001"
# SEARCH
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(
data_array=[["mem-001", "h1", None, None, "u1", "test memory", None, None, None, 0.95]]
)
)
results = db.search(query="test", vectors=None, top_k=5)
assert len(results) == 1
assert results[0].id == "mem-001"
assert results[0].payload["data"] == "test memory"
search_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert search_kwargs["query_text"] == "test"
# GET
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
manifest=ResultManifest(columns=[
ColumnInfo(name="memory_id"), ColumnInfo(name="hash"),
ColumnInfo(name="agent_id"), ColumnInfo(name="run_id"),
ColumnInfo(name="user_id"), ColumnInfo(name="memory"),
ColumnInfo(name="metadata"), ColumnInfo(name="created_at"),
ColumnInfo(name="updated_at"),
]),
result=ResultData(data_array=[["mem-001", "h1", None, None, "u1", "test memory", None, None, None]]),
)
got = db.get("mem-001")
assert got.id == "mem-001"
assert got.payload["data"] == "test memory"
get_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_text" in get_kwargs
assert "query_vector" not in get_kwargs
# LIST
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(
data_array=[["mem-001", "h1", None, None, "u1", "test memory", None, None, None]]
)
)
listed = db.list(filters={"user_id": "u1"}, top_k=10)
assert len(listed[0]) == 1
assert listed[0][0].id == "mem-001"
list_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_text" in list_kwargs
assert list_kwargs["num_results"] == 10
# UPDATE
db.update(vector_id="mem-001", payload={"memory": "updated memory"})
update_call = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
update_sql = update_call["statement"]
assert "UPDATE cat.sch.tbl" in update_sql
assert ":vector_id" in update_sql
assert ":payload_memory" in update_sql
update_params = {p.name: p.value for p in update_call["parameters"]}
assert update_params["vector_id"] == "mem-001"
assert update_params["payload_memory"] == "updated memory"
# DELETE
db.delete("mem-001")
delete_call = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
delete_sql = delete_call["statement"]
assert "DELETE FROM cat.sch.tbl" in delete_sql
assert ":vector_id" in delete_sql
delete_params = {p.name: p.value for p in delete_call["parameters"]}
assert delete_params["vector_id"] == "mem-001"
def test_e2e_crud_lifecycle_direct_access(mock_workspace_client):
"""End-to-end CRUD lifecycle for DIRECT_ACCESS: insert → search → get → list."""
from mem0.utils.factory import VectorStoreFactory
from mem0.vector_stores.configs import VectorStoreConfig
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
vs_config = VectorStoreConfig(
provider="databricks",
config={
"workspace_url": "https://test",
"access_token": "tok",
"endpoint_name": "ep",
"catalog": "cat",
"schema": "sch",
"table_name": "tbl",
"index_type": "DIRECT_ACCESS",
"embedding_dimension": 4,
"warehouse_name": "test-warehouse",
"embedding_model_endpoint_name": "emb-ep",
},
)
db = VectorStoreFactory.create("databricks", vs_config.config)
assert "embedding" in db.column_names
# INSERT with vector
db.insert(
vectors=[[0.1, 0.2, 0.3, 0.4]],
payloads=[{"data": "direct memory", "user_id": "u1", "hash": "h1"}],
ids=["mem-da-001"],
)
insert_call = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
insert_sql = insert_call["statement"]
assert "array(0.1, 0.2, 0.3, 0.4)" in insert_sql
insert_params = {p.name: p.value for p in insert_call["parameters"]}
assert insert_params["memory_id_0"] == "mem-da-001"
# SEARCH with vector
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(
data_array=[["mem-da-001", "h1", None, None, "u1", "direct memory", None, None, None, [0.1, 0.2, 0.3, 0.4], 0.9]]
)
)
results = db.search(query="", vectors=[0.1, 0.2, 0.3, 0.4], top_k=5)
assert len(results) == 1
search_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in search_kwargs
assert "query_text" not in search_kwargs
# GET — must use query_vector for DIRECT_ACCESS
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
manifest=ResultManifest(columns=[
ColumnInfo(name="memory_id"), ColumnInfo(name="hash"),
ColumnInfo(name="agent_id"), ColumnInfo(name="run_id"),
ColumnInfo(name="user_id"), ColumnInfo(name="memory"),
ColumnInfo(name="metadata"), ColumnInfo(name="created_at"),
ColumnInfo(name="updated_at"), ColumnInfo(name="embedding"),
]),
result=ResultData(data_array=[["mem-da-001", "h1", None, None, "u1", "direct memory", None, None, None, [0.1, 0.2, 0.3, 0.4]]]),
)
got = db.get("mem-da-001")
assert got.id == "mem-da-001"
get_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in get_kwargs
assert get_kwargs["query_vector"] == [0.0] * 4
# LIST — must use query_vector for DIRECT_ACCESS
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
result=SimpleNamespace(
data_array=[["mem-da-001", "h1", None, None, "u1", "direct memory", None, None, None, [0.1, 0.2, 0.3, 0.4]]]
)
)
db.list(top_k=5)
list_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
assert "query_vector" in list_kwargs
assert "query_text" not in list_kwargs
# ---------------------- SQL Injection Prevention Tests (Issue #4073) ---------------------- #
SQLI_PAYLOADS = [
"'; DELETE FROM table; --",
"' OR '1'='1",
"'; DROP TABLE memories; --",
"1' UNION SELECT * FROM secrets --",
]
@pytest.mark.parametrize("malicious_id", SQLI_PAYLOADS)
def test_delete_prevents_sql_injection(db_instance_delta, mock_workspace_client, malicious_id):
"""Verify delete() uses parameterized queries so SQL injection payloads are never in the SQL string."""
db_instance_delta.delete(malicious_id)
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
# The malicious payload must NOT appear in the SQL statement itself
assert malicious_id not in sql
assert ":vector_id" in sql
# It should be safely passed as a parameter
assert kwargs["parameters"][0].value == malicious_id
@pytest.mark.parametrize("malicious_id", SQLI_PAYLOADS)
def test_update_prevents_sql_injection_in_vector_id(db_instance_delta, mock_workspace_client, malicious_id):
"""Verify update() parameterizes vector_id."""
db_instance_delta.update(vector_id=malicious_id, payload={"memory": "safe value"})
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
assert malicious_id not in sql
assert ":vector_id" in sql
param_map = {p.name: p.value for p in kwargs["parameters"]}
assert param_map["vector_id"] == malicious_id
@pytest.mark.parametrize("malicious_value", SQLI_PAYLOADS)
def test_update_prevents_sql_injection_in_payload(db_instance_delta, mock_workspace_client, malicious_value):
"""Verify update() parameterizes payload values."""
db_instance_delta.update(vector_id="safe-id", payload={"memory": malicious_value})
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
assert malicious_value not in sql
assert ":payload_memory" in sql
param_map = {p.name: p.value for p in kwargs["parameters"]}
assert param_map["payload_memory"] == malicious_value
@pytest.mark.parametrize("malicious_id", SQLI_PAYLOADS)
def test_insert_prevents_sql_injection_in_ids(db_instance_delta, mock_workspace_client, malicious_id):
"""Verify insert() parameterizes memory IDs."""
db_instance_delta.insert(
vectors=[[0.1, 0.2]],
payloads=[{"data": "test", "hash": "h1"}],
ids=[malicious_id],
)
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
assert malicious_id not in sql
assert ":memory_id_0" in sql
param_map = {p.name: p.value for p in kwargs["parameters"]}
assert param_map["memory_id_0"] == malicious_id
@pytest.mark.parametrize("malicious_data", SQLI_PAYLOADS)
def test_insert_prevents_sql_injection_in_payload_data(db_instance_delta, mock_workspace_client, malicious_data):
"""Verify insert() parameterizes payload data values."""
db_instance_delta.insert(
vectors=[[0.1, 0.2]],
payloads=[{"data": malicious_data, "hash": "h1"}],
ids=["safe-id"],
)
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
assert malicious_data not in sql
param_map = {p.name: p.value for p in kwargs["parameters"]}
assert param_map["memory_0"] == malicious_data
@pytest.mark.parametrize("malicious_key", [
"memory; DROP TABLE x--",
"col' OR '1'='1",
"valid_col; DELETE FROM t",
"col\nname",
])
def test_update_rejects_malicious_column_names(db_instance_delta, mock_workspace_client, malicious_key):
"""Verify update() skips payload keys that are not valid SQL identifiers."""
db_instance_delta.update(
vector_id="safe-id",
payload={malicious_key: "some value", "memory": "legit"},
)
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
# The malicious key must NOT appear as a column name in the SQL
assert malicious_key not in sql
# The legitimate key should still be present
assert ":payload_memory" in sql
def test_insert_multi_row(db_instance_delta, mock_workspace_client):
"""Verify multi-row insert generates unique parameter names per row."""
db_instance_delta.insert(
vectors=[[0.1, 0.2], [0.3, 0.4]],
payloads=[
{"data": "first memory", "hash": "h1"},
{"data": "second memory", "hash": "h2"},
],
ids=["id-1", "id-2"],
)
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
params = kwargs["parameters"]
param_map = {p.name: p.value for p in params}
# Each row should have distinct parameter names
assert ":memory_id_0" in sql
assert ":memory_id_1" in sql
assert param_map["memory_id_0"] == "id-1"
assert param_map["memory_id_1"] == "id-2"
assert param_map["memory_0"] == "first memory"
assert param_map["memory_1"] == "second memory"
def test_insert_with_none_values(db_instance_delta, mock_workspace_client):
"""Verify insert() uses NULL literal for None values, not parameters."""
db_instance_delta.insert(
vectors=[[0.1, 0.2]],
payloads=[{"data": "test", "hash": "h1"}], # agent_id, run_id etc will be None
ids=["id-1"],
)
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
assert "NULL" in sql # None values should be literal NULL
# Only non-None values should have parameters
param_names = {p.name for p in kwargs["parameters"]}
assert "memory_id_0" in param_names
assert "memory_0" in param_names
assert "hash_0" in param_names
def test_update_payload_only(db_instance_delta, mock_workspace_client):
"""Verify update() works with payload only (no vector)."""
db_instance_delta.update(vector_id="id-1", payload={"memory": "new text"})
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
assert "UPDATE" in sql
assert "embedding" not in sql
assert ":payload_memory" in sql
assert ":vector_id" in sql
def test_update_vector_only(db_instance_direct, mock_workspace_client):
"""Verify update() works with vector only (no payload)."""
db_instance_direct.update(vector_id="id-1", vector=[0.1, 0.2, 0.3, 0.4])
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
sql = kwargs["statement"]
assert "UPDATE" in sql
assert "embedding = array(0.1, 0.2, 0.3, 0.4)" in sql
assert ":vector_id" in sql
param_map = {p.name: p.value for p in kwargs["parameters"]}
assert param_map["vector_id"] == "id-1"
assert len(kwargs["parameters"]) == 1 # only vector_id param
def test_insert_timestamp_params_have_explicit_type(db_instance_delta, mock_workspace_client):
"""Verify insert() sets type='TIMESTAMP' on created_at/updated_at parameters
so Databricks doesn't rely on implicit STRING->TIMESTAMP casting."""
db_instance_delta.insert(
vectors=[[0.1, 0.2]],
payloads=[{
"data": "test",
"hash": "h1",
"created_at": "2024-01-01T00:00:00+00:00",
"updated_at": "2024-01-02T00:00:00+00:00",
}],
ids=["id-1"],
)
kwargs = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs
params = kwargs["parameters"]
ts_params = [p for p in params if "created_at" in p.name or "updated_at" in p.name]
assert len(ts_params) == 2
for p in ts_params:
assert p.type == "TIMESTAMP", f"Parameter {p.name} should have type=TIMESTAMP, got {p.type}"
# Non-timestamp params should not have a type set (defaults to STRING)
non_ts_params = [p for p in params if "created_at" not in p.name and "updated_at" not in p.name]
for p in non_ts_params:
assert p.type is None, f"Parameter {p.name} should not have explicit type, got {p.type}"
+4 -4
View File
@@ -9,8 +9,8 @@ try:
except ImportError:
raise ImportError("Elasticsearch requires extra dependencies. Install with `pip install elasticsearch`") from None
from mem0.vector_stores.elasticsearch import ElasticsearchDB, OutputData
from mem0.configs.vector_stores.elasticsearch import ElasticsearchConfig
from mem0.vector_stores.elasticsearch import ElasticsearchDB, OutputData
class TestElasticsearchDB(unittest.TestCase):
@@ -189,7 +189,7 @@ class TestElasticsearchDB(unittest.TestCase):
# Perform search
vectors = [[0.1] * 1536]
results = self.es_db.search(query="", vectors=vectors, limit=5)
results = self.es_db.search(query="", vectors=vectors, top_k=5)
# Verify search call
self.client_mock.search.assert_called_once()
@@ -221,7 +221,7 @@ class TestElasticsearchDB(unittest.TestCase):
vectors = [[0.1] * 1536]
limit = 5
filters = {"key1": "value1"}
self.es_db.search(query="", vectors=vectors, limit=limit, filters=filters)
self.es_db.search(query="", vectors=vectors, top_k=limit, filters=filters)
# Verify custom search query function was called
self.es_db.custom_search_query.assert_called_once_with(vectors, limit, filters)
@@ -272,7 +272,7 @@ class TestElasticsearchDB(unittest.TestCase):
self.client_mock.search.return_value = mock_response
# Perform list operation
results = self.es_db.list(limit=10)
results = self.es_db.list(top_k=10)
# Verify search call
self.client_mock.search.assert_called_once()
+17 -7
View File
@@ -101,7 +101,7 @@ def test_search(faiss_instance, mock_faiss_index):
with patch.object(faiss_instance, "_parse_output", return_value=expected_results):
# Call search
results = faiss_instance.search(query="test query", vectors=query_vector, limit=2)
results = faiss_instance.search(query="test query", vectors=query_vector, top_k=2)
# Verify numpy.array was called (but we don't check exact call arguments since it's complex)
assert mock_np_array.called
@@ -148,7 +148,7 @@ def test_search_with_filters(faiss_instance, mock_faiss_index):
with patch.object(faiss_instance, "_apply_filters", side_effect=lambda p, f: p.get("category") == "A"):
# Call search with filters
results = faiss_instance.search(
query="test query", vectors=query_vector, limit=2, filters={"category": "A"}
query="test query", vectors=query_vector, top_k=2, filters={"category": "A"}
)
# Verify numpy.array was called
@@ -165,19 +165,29 @@ def test_search_with_filters(faiss_instance, mock_faiss_index):
assert results[0].payload == {"name": "vector1", "category": "A"}
def test_delete(faiss_instance):
def test_delete(faiss_instance, mock_faiss_index):
# Setup the docstore and index_to_id mapping
faiss_instance.docstore = {"id1": {"name": "vector1"}, "id2": {"name": "vector2"}}
faiss_instance.index_to_id = {0: "id1", 1: "id2"}
# Mock reconstruct to return vectors for remaining entries
mock_faiss_index.reconstruct.side_effect = lambda idx: np.array(
[0.1, 0.2, 0.3] if idx == 0 else [0.4, 0.5, 0.6], dtype=np.float32
)
# Call delete
faiss_instance.delete(vector_id="id1")
# Verify the vector was removed from docstore and index_to_id
# Verify the vector was removed from docstore
assert "id1" not in faiss_instance.docstore
assert 0 not in faiss_instance.index_to_id
assert "id2" in faiss_instance.docstore
assert 1 in faiss_instance.index_to_id
# Verify the FAISS index was rebuilt
mock_faiss_index.reset.assert_called_once()
mock_faiss_index.add.assert_called_once()
# Verify index_to_id was remapped contiguously
assert faiss_instance.index_to_id == {0: "id2"}
def test_update(faiss_instance, mock_faiss_index):
@@ -231,7 +241,7 @@ def test_list(faiss_instance):
assert len(results[0]) == 3
# Test listing with a limit
results = faiss_instance.list(limit=2)
results = faiss_instance.list(top_k=2)
assert len(results[0]) == 2
# Test listing with filters
@@ -48,7 +48,7 @@ def test_search_vectors(langchain_instance):
# Test search without filters
vectors = [[0.1, 0.2, 0.3]]
results = langchain_instance.search(query="", vectors=vectors, limit=2)
results = langchain_instance.search(query="", vectors=vectors, top_k=2)
langchain_instance.client.similarity_search_by_vector.assert_called_once_with(embedding=vectors, k=2)
@@ -60,7 +60,7 @@ def test_search_vectors(langchain_instance):
# Test search with filters
filters = {"name": "vector1"}
langchain_instance.search(query="", vectors=vectors, limit=2, filters=filters)
langchain_instance.search(query="", vectors=vectors, top_k=2, filters=filters)
langchain_instance.client.similarity_search_by_vector.assert_called_with(embedding=vectors, k=2, filter=filters)
@@ -75,7 +75,7 @@ def test_search_vectors_with_agent_id_run_id_filters(langchain_instance):
vectors = [[0.1, 0.2, 0.3]]
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = langchain_instance.search(query="", vectors=vectors, limit=2, filters=filters)
results = langchain_instance.search(query="", vectors=vectors, top_k=2, filters=filters)
# Verify that filters were passed to the underlying vector store
langchain_instance.client.similarity_search_by_vector.assert_called_once_with(
@@ -96,7 +96,7 @@ def test_search_vectors_with_single_filter(langchain_instance):
vectors = [[0.1, 0.2, 0.3]]
filters = {"user_id": "alice"}
results = langchain_instance.search(query="", vectors=vectors, limit=2, filters=filters)
results = langchain_instance.search(query="", vectors=vectors, top_k=2, filters=filters)
# Verify that filters were passed to the underlying vector store
langchain_instance.client.similarity_search_by_vector.assert_called_once_with(
@@ -114,7 +114,7 @@ def test_search_vectors_with_no_filters(langchain_instance):
langchain_instance.client.similarity_search_by_vector.return_value = mock_docs
vectors = [[0.1, 0.2, 0.3]]
results = langchain_instance.search(query="", vectors=vectors, limit=2, filters=None)
results = langchain_instance.search(query="", vectors=vectors, top_k=2, filters=None)
# Verify that no filters were passed to the underlying vector store
langchain_instance.client.similarity_search_by_vector.assert_called_once_with(
@@ -155,7 +155,7 @@ def test_list_with_filters(langchain_instance):
langchain_instance.client._collection = mock_collection
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = langchain_instance.list(filters=filters, limit=10)
results = langchain_instance.list(filters=filters, top_k=10)
# Verify that the collection.get method was called with the correct filters
mock_collection.get.assert_called_once_with(where=filters, limit=10)
@@ -180,7 +180,7 @@ def test_list_with_single_filter(langchain_instance):
langchain_instance.client._collection = mock_collection
filters = {"user_id": "alice"}
results = langchain_instance.list(filters=filters, limit=10)
results = langchain_instance.list(filters=filters, top_k=10)
# Verify that the collection.get method was called with the correct filter
mock_collection.get.assert_called_once_with(where=filters, limit=10)
@@ -202,7 +202,7 @@ def test_list_with_no_filters(langchain_instance):
}
langchain_instance.client._collection = mock_collection
results = langchain_instance.list(filters=None, limit=10)
results = langchain_instance.list(filters=None, top_k=10)
# Verify that the collection.get method was called with no filters
mock_collection.get.assert_called_once_with(where=None, limit=10)
@@ -220,7 +220,28 @@ def test_list_with_exception(langchain_instance):
mock_collection.get.side_effect = Exception("Test exception")
langchain_instance.client._collection = mock_collection
results = langchain_instance.list(filters={"user_id": "alice"}, limit=10)
results = langchain_instance.list(filters={"user_id": "alice"}, top_k=10)
# Verify that an empty list is returned when an exception occurs
assert results == []
def test_update_wraps_vector_and_payload_in_lists(langchain_instance):
"""Regression test for Langchain update() type mismatch.
update() must wrap vector and payload in lists before calling insert(),
which expects List[List[float]] and List[Dict] respectively.
"""
langchain_instance.client.delete = Mock()
langchain_instance.client.add_embeddings = Mock()
vector = [0.1, 0.2, 0.3]
payload = {"data": "updated text", "name": "updated"}
vector_id = "id1"
langchain_instance.update(vector_id=vector_id, vector=vector, payload=payload)
langchain_instance.client.delete.assert_called_once_with(ids=[vector_id])
langchain_instance.client.add_embeddings.assert_called_once_with(
embeddings=[vector], metadatas=[payload], ids=[vector_id]
)
+75 -4
View File
@@ -8,10 +8,12 @@ These tests verify:
4. Update/upsert operations
"""
import pytest
from unittest.mock import MagicMock, patch
from mem0.vector_stores.milvus import MilvusDB
import pytest
from mem0.configs.vector_stores.milvus import MetricType
from mem0.vector_stores.milvus import MilvusDB
class TestMilvusDB:
@@ -118,7 +120,7 @@ class TestMilvusDB:
results = milvus_db.search(
query="test query",
vectors=query_vector,
limit=5,
top_k=5,
filters=filters
)
@@ -199,7 +201,7 @@ class TestMilvusDB:
{"id": "mem2", "metadata": {"user_id": "alice"}}
]
results = milvus_db.list(filters={"user_id": "alice"}, limit=10)
results = milvus_db.list(filters={"user_id": "alice"}, top_k=10)
# Verify query was called with filter
call_args = mock_milvus_client.query.call_args
@@ -233,6 +235,75 @@ class TestMilvusDB:
assert parsed[1].id == "mem2"
assert parsed[1].score == 0.85
def test_update_with_none_vector_fetches_existing(self, milvus_db, mock_milvus_client):
"""Test that update with vector=None fetches the existing vector (fixes #3708)."""
vector_id = "test_id"
existing_vector = [0.5] * 1536
payload = {"user_id": "alice", "data": "Updated memory"}
mock_milvus_client.get.return_value = [
{"id": vector_id, "vectors": existing_vector, "metadata": {"user_id": "alice"}}
]
milvus_db.update(vector_id=vector_id, vector=None, payload=payload)
mock_milvus_client.get.assert_called_once_with(
collection_name="test_collection", ids=vector_id
)
call_args = mock_milvus_client.upsert.call_args
assert call_args[1]['data']['vectors'] == existing_vector
assert call_args[1]['data']['metadata'] == payload
def test_update_with_none_payload_fetches_existing(self, milvus_db, mock_milvus_client):
"""Test that update with payload=None fetches the existing metadata."""
vector_id = "test_id"
vector = [0.1] * 1536
existing_metadata = {"user_id": "alice", "data": "Original"}
mock_milvus_client.get.return_value = [
{"id": vector_id, "vectors": [0.5] * 1536, "metadata": existing_metadata}
]
milvus_db.update(vector_id=vector_id, vector=vector, payload=None)
call_args = mock_milvus_client.upsert.call_args
assert call_args[1]['data']['vectors'] == vector
assert call_args[1]['data']['metadata'] == existing_metadata
def test_update_with_both_none_fetches_existing(self, milvus_db, mock_milvus_client):
"""Test that update with both vector=None and payload=None fetches existing data."""
vector_id = "test_id"
existing_vector = [0.5] * 1536
existing_metadata = {"user_id": "alice"}
mock_milvus_client.get.return_value = [
{"id": vector_id, "vectors": existing_vector, "metadata": existing_metadata}
]
milvus_db.update(vector_id=vector_id, vector=None, payload=None)
# Should only call get once even though both are None
assert mock_milvus_client.get.call_count == 1
call_args = mock_milvus_client.upsert.call_args
assert call_args[1]['data']['vectors'] == existing_vector
assert call_args[1]['data']['metadata'] == existing_metadata
def test_update_with_none_vector_raises_on_missing_record(self, milvus_db, mock_milvus_client):
"""Test that update raises ValueError when the record doesn't exist."""
mock_milvus_client.get.return_value = []
with pytest.raises(ValueError, match="not found"):
milvus_db.update(vector_id="nonexistent", vector=None, payload={"data": "test"})
def test_update_with_none_vector_raises_on_missing_vector_data(self, milvus_db, mock_milvus_client):
"""Test that update raises ValueError when existing record has no vector."""
mock_milvus_client.get.return_value = [
{"id": "test_id", "vectors": None, "metadata": {"user_id": "alice"}}
]
with pytest.raises(ValueError, match="no vector data"):
milvus_db.update(vector_id="test_id", vector=None, payload={"data": "test"})
def test_collection_already_exists(self, mock_milvus_client):
"""Test that existing collection is not recreated."""
mock_milvus_client.has_collection.return_value = True
+63 -20
View File
@@ -48,17 +48,16 @@ def test_initalize_create_col(mongo_vector_fixture):
search_index_model = args[0].document
assert search_index_model == {
"name": "test_collection_vector_index",
"type": "vectorSearch",
"definition": {
"mappings": {
"dynamic": False,
"fields": {
"embedding": {
"type": "knnVector",
"dimensions": 1536,
"similarity": "cosine",
}
},
}
"fields": [
{
"type": "vector",
"path": "embedding",
"numDimensions": 1536,
"similarity": "cosine",
}
]
},
}
assert mongo_vector.collection == mock_collection
@@ -87,7 +86,7 @@ def test_search(mongo_vector_fixture):
]
mock_collection.list_search_indexes.return_value = ["test_collection_vector_index"]
results = mongo_vector.search("query_str", query_vector, limit=2)
results = mongo_vector.search("query_str", query_vector, top_k=2)
mock_collection.list_search_indexes.assert_called_with(name="test_collection_vector_index")
mock_collection.aggregate.assert_called_once_with(
[
@@ -95,7 +94,7 @@ def test_search(mongo_vector_fixture):
"$vectorSearch": {
"index": "test_collection_vector_index",
"limit": 2,
"numCandidates": 2,
"numCandidates": 40,
"queryVector": query_vector,
"path": "embedding",
},
@@ -120,7 +119,7 @@ def test_search_with_filters(mongo_vector_fixture):
mock_collection.list_search_indexes.return_value = ["test_collection_vector_index"]
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = mongo_vector.search("query_str", query_vector, limit=2, filters=filters)
results = mongo_vector.search("query_str", query_vector, top_k=2, filters=filters)
# Verify that the aggregation pipeline includes the filter stage
mock_collection.aggregate.assert_called_once()
@@ -154,7 +153,7 @@ def test_search_with_single_filter(mongo_vector_fixture):
mock_collection.list_search_indexes.return_value = ["test_collection_vector_index"]
filters = {"user_id": "alice"}
results = mongo_vector.search("query_str", query_vector, limit=2, filters=filters)
results = mongo_vector.search("query_str", query_vector, top_k=2, filters=filters)
# Verify that the aggregation pipeline includes the filter stage
mock_collection.aggregate.assert_called_once()
@@ -178,7 +177,7 @@ def test_search_with_no_filters(mongo_vector_fixture):
]
mock_collection.list_search_indexes.return_value = ["test_collection_vector_index"]
results = mongo_vector.search("query_str", query_vector, limit=2, filters=None)
results = mongo_vector.search("query_str", query_vector, top_k=2, filters=None)
# Verify that the aggregation pipeline does not include the filter stage
mock_collection.aggregate.assert_called_once()
@@ -204,6 +203,10 @@ def test_delete(mongo_vector_fixture):
def test_update(mongo_vector_fixture):
"""
Test that update() uses dot notation for payload fields instead of replacing
the entire payload document.
"""
mongo_vector, mock_collection, _ = mongo_vector_fixture
vector_id = "id1"
updated_vector = [0.3] * 1536
@@ -213,11 +216,51 @@ def test_update(mongo_vector_fixture):
mongo_vector.update(vector_id=vector_id, vector=updated_vector, payload=updated_payload)
# Should use dot notation (payload.name) instead of full replacement (payload)
mock_collection.update_one.assert_called_once_with(
{"_id": vector_id}, {"$set": {"embedding": updated_vector, "payload": updated_payload}}
{"_id": vector_id}, {"$set": {"embedding": updated_vector, "payload.name": "updated_vector"}}
)
def test_update_payload_only_uses_dot_notation(mongo_vector_fixture):
"""
Test that updating only the payload uses dot notation for each field,
preserving existing metadata fields not included in the update.
"""
mongo_vector, mock_collection, _ = mongo_vector_fixture
mock_collection.update_one.return_value = MagicMock(matched_count=1)
mongo_vector.update(
vector_id="id1",
payload={"data": "updated text", "hash": "def456", "updated_at": "2025-06-01"},
)
set_arg = mock_collection.update_one.call_args[0][1]["$set"]
# Only the specified fields should be in $set, using dot notation
assert set_arg == {
"payload.data": "updated text",
"payload.hash": "def456",
"payload.updated_at": "2025-06-01",
}
# "payload" key itself should NOT appear (that would replace the whole document)
assert "payload" not in set_arg
def test_update_vector_only_does_not_touch_payload(mongo_vector_fixture):
"""Test that updating only the vector does not touch any payload fields."""
mongo_vector, mock_collection, _ = mongo_vector_fixture
mock_collection.update_one.return_value = MagicMock(matched_count=1)
mongo_vector.update(vector_id="id1", vector=[0.7, 0.8, 0.9])
set_arg = mock_collection.update_one.call_args[0][1]["$set"]
assert set_arg == {"embedding": [0.7, 0.8, 0.9]}
assert not any(k.startswith("payload.") for k in set_arg)
def test_get(mongo_vector_fixture):
mongo_vector, mock_collection, _ = mongo_vector_fixture
vector_id = "id1"
@@ -272,7 +315,7 @@ def test_list(mongo_vector_fixture):
{"_id": "id2", "payload": {"key": "value2"}},
]
results = mongo_vector.list(limit=2)
results = mongo_vector.list(top_k=2)
mock_collection.find.assert_called_once_with({})
mock_cursor.limit.assert_called_once_with(2)
@@ -291,7 +334,7 @@ def test_list_with_filters(mongo_vector_fixture):
]
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = mongo_vector.list(filters=filters, limit=2)
results = mongo_vector.list(filters=filters, top_k=2)
# Verify that the find method was called with the correct query
expected_query = {
@@ -320,7 +363,7 @@ def test_list_with_single_filter(mongo_vector_fixture):
]
filters = {"user_id": "alice"}
results = mongo_vector.list(filters=filters, limit=2)
results = mongo_vector.list(filters=filters, top_k=2)
# Verify that the find method was called with the correct query
expected_query = {
@@ -344,7 +387,7 @@ def test_list_with_no_filters(mongo_vector_fixture):
{"_id": "id1", "payload": {"key": "value1"}},
]
results = mongo_vector.list(filters=None, limit=2)
results = mongo_vector.list(filters=None, top_k=2)
# Verify that the find method was called with empty query
mock_collection.find.assert_called_once_with({})
@@ -123,7 +123,7 @@ class TestNeptuneAnalyticsOperations:
payloads=SAMPLE_PAYLOADS
)
result = na_instance.search(query="", vectors=VECTOR_1, limit=1)
result = na_instance.search(query="", vectors=VECTOR_1, top_k=1)
assert len(result) == 1
assert "label" not in result[0].payload
+146 -44
View File
@@ -18,25 +18,25 @@ from mem0.vector_stores.opensearch import OpenSearchDB
# Mock classes for testing OpenSearch with AWS authentication
class MockFieldInfo:
"""Mock pydantic field info."""
def __init__(self, default=None):
self.default = default
class MockOpenSearchConfig:
model_fields = {
'collection_name': MockFieldInfo(default="default_collection"),
'host': MockFieldInfo(default="localhost"),
'port': MockFieldInfo(default=9200),
'embedding_model_dims': MockFieldInfo(default=1536),
'http_auth': MockFieldInfo(default=None),
'auth': MockFieldInfo(default=None),
'credentials': MockFieldInfo(default=None),
'connection_class': MockFieldInfo(default=None),
'use_ssl': MockFieldInfo(default=False),
'verify_certs': MockFieldInfo(default=False),
"collection_name": MockFieldInfo(default="default_collection"),
"host": MockFieldInfo(default="localhost"),
"port": MockFieldInfo(default=9200),
"embedding_model_dims": MockFieldInfo(default=1536),
"http_auth": MockFieldInfo(default=None),
"auth": MockFieldInfo(default=None),
"credentials": MockFieldInfo(default=None),
"connection_class": MockFieldInfo(default=None),
"use_ssl": MockFieldInfo(default=False),
"verify_certs": MockFieldInfo(default=False),
}
def __init__(self, collection_name="test_collection", include_auth=True, **kwargs):
self.collection_name = collection_name
self.host = kwargs.get("host", "localhost")
@@ -44,7 +44,7 @@ class MockOpenSearchConfig:
self.embedding_model_dims = kwargs.get("embedding_model_dims", 1536)
self.use_ssl = kwargs.get("use_ssl", True)
self.verify_certs = kwargs.get("verify_certs", True)
if any(field in kwargs for field in ["http_auth", "auth", "credentials", "connection_class"]):
self.http_auth = kwargs.get("http_auth")
self.auth = kwargs.get("auth")
@@ -63,20 +63,18 @@ class MockOpenSearchConfig:
class MockAWSAuth:
def __init__(self):
self._lock = threading.Lock()
self.region = "us-east-1"
def __deepcopy__(self, memo):
raise TypeError("cannot pickle '_thread.lock' object")
class MockConnectionClass:
def __init__(self):
self._state = {"connected": False}
def __deepcopy__(self, memo):
raise TypeError("cannot pickle connection state")
@@ -231,7 +229,7 @@ class TestOpenSearchDB(unittest.TestCase):
}
self.client_mock.search.return_value = mock_response
vectors = [[0.1] * 1536]
results = self.os_db.search(query="", vectors=vectors, limit=5)
results = self.os_db.search(query="", vectors=vectors, top_k=5)
self.client_mock.search.assert_called_once()
search_args = self.client_mock.search.call_args[1]
self.assertEqual(search_args["index"], "test_collection")
@@ -255,6 +253,104 @@ class TestOpenSearchDB(unittest.TestCase):
self.os_db.delete_col()
self.client_mock.indices.delete.assert_called_once_with(index="test_collection")
def test_insert_rejects_null_vectors(self):
"""Vectors that are None should raise ValueError before hitting OpenSearch."""
vectors = [None]
payloads = [{"key": "value"}]
ids = ["id1"]
with self.assertRaises(ValueError) as ctx:
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
self.assertIn("null", str(ctx.exception).lower())
self.client_mock.index.assert_not_called()
def test_insert_rejects_empty_vectors(self):
"""Empty vector lists should raise ValueError."""
vectors = [[]]
payloads = [{"key": "value"}]
ids = ["id1"]
with self.assertRaises(ValueError) as ctx:
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
self.assertIn("empty", str(ctx.exception).lower())
self.client_mock.index.assert_not_called()
def test_insert_rejects_dimension_mismatch(self):
"""Vectors with wrong dimensions should raise ValueError with a clear message."""
vectors = [[0.1] * 768]
payloads = [{"key": "value"}]
ids = ["id1"]
with self.assertRaises(ValueError) as ctx:
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
error_msg = str(ctx.exception)
self.assertIn("768", error_msg)
self.assertIn("1536", error_msg)
self.client_mock.index.assert_not_called()
def test_update_rejects_empty_vector(self):
"""Update with an empty vector should raise ValueError."""
with self.assertRaises(ValueError) as ctx:
self.os_db.update("id1", vector=[], payload={"key": "value"})
self.assertIn("empty", str(ctx.exception).lower())
self.client_mock.search.assert_not_called()
def test_update_rejects_dimension_mismatch(self):
"""Update with wrong vector dimensions should raise ValueError."""
vector = [0.1] * 768
payload = {"key": "value"}
with self.assertRaises(ValueError) as ctx:
self.os_db.update("id1", vector=vector, payload=payload)
error_msg = str(ctx.exception)
self.assertIn("768", error_msg)
self.assertIn("1536", error_msg)
self.client_mock.search.assert_not_called()
@patch("mem0.vector_stores.opensearch.logger")
def test_update_error_logs_with_exc_info(self, mock_logger):
"""Update errors should log with exc_info and re-raise."""
mock_search_response = {"hits": {"hits": [{"_id": "doc1", "_source": {"id": "id1"}}]}}
self.client_mock.search.return_value = mock_search_response
self.client_mock.update.side_effect = Exception("Update failed")
with self.assertRaises(Exception):
self.os_db.update("id1", vector=[0.1] * 1536, payload={"key": "value"})
mock_logger.error.assert_called_once()
call_kwargs = mock_logger.error.call_args
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
@patch("mem0.vector_stores.opensearch.logger")
def test_insert_error_logs_with_exc_info(self, mock_logger):
"""Error logging should include exc_info for full stack trace."""
vectors = [[0.1] * 1536]
payloads = [{"key": "value"}]
ids = ["id1"]
self.client_mock.index.side_effect = Exception("Connection refused")
with self.assertRaises(Exception):
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
mock_logger.error.assert_called_once()
call_kwargs = mock_logger.error.call_args
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
@patch("mem0.vector_stores.opensearch.logger")
def test_search_error_logs_with_exc_info(self, mock_logger):
"""Search error logging should include exc_info for full stack trace."""
self.client_mock.search.side_effect = Exception("Search failed")
results = self.os_db.search(query="", vectors=[[0.1] * 1536], top_k=5)
self.assertEqual(results, [])
mock_logger.error.assert_called_once()
call_kwargs = mock_logger.error.call_args
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
def test_init_with_http_auth(self):
mock_credentials = MagicMock()
mock_signer = AWSV4SignerAuth(mock_credentials, "us-east-1", "es")
@@ -282,11 +378,13 @@ class TestOpenSearchDB(unittest.TestCase):
# Tests for OpenSearch config deepcopy with AWS authentication (Issue #3464)
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_safe_deepcopy_config_handles_opensearch_auth(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
@patch("mem0.utils.factory.EmbedderFactory.create")
@patch("mem0.utils.factory.VectorStoreFactory.create")
@patch("mem0.utils.factory.LlmFactory.create")
@patch("mem0.memory.storage.SQLiteManager")
def test_safe_deepcopy_config_handles_opensearch_auth(
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
):
"""Test that _safe_deepcopy_config handles OpenSearch configs with AWS auth objects gracefully."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
@@ -295,16 +393,18 @@ def test_safe_deepcopy_config_handles_opensearch_auth(mock_sqlite, mock_llm_fact
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import _safe_deepcopy_config
config_with_auth = MockOpenSearchConfig(collection_name="opensearch_test", include_auth=True)
safe_config = _safe_deepcopy_config(config_with_auth)
assert safe_config.http_auth is None
assert safe_config.auth is None
# Runtime auth objects must be preserved (Issue #3580)
assert safe_config.http_auth is not None
assert safe_config.auth is not None
assert safe_config.connection_class is not None
# Credentials dict is a sensitive secret and should be redacted
assert safe_config.credentials is None
assert safe_config.connection_class is None
assert safe_config.collection_name == "opensearch_test"
assert safe_config.host == "localhost"
assert safe_config.port == 9200
@@ -313,10 +413,10 @@ def test_safe_deepcopy_config_handles_opensearch_auth(mock_sqlite, mock_llm_fact
assert safe_config.verify_certs is True
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
@patch("mem0.utils.factory.EmbedderFactory.create")
@patch("mem0.utils.factory.VectorStoreFactory.create")
@patch("mem0.utils.factory.LlmFactory.create")
@patch("mem0.memory.storage.SQLiteManager")
def test_safe_deepcopy_config_normal_configs(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that _safe_deepcopy_config handles normal OpenSearch configs without auth."""
mock_embedder_factory.return_value = MagicMock()
@@ -326,12 +426,12 @@ def test_safe_deepcopy_config_normal_configs(mock_sqlite, mock_llm_factory, mock
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import _safe_deepcopy_config
config_without_auth = MockOpenSearchConfig(collection_name="normal_test", include_auth=False)
safe_config = _safe_deepcopy_config(config_without_auth)
assert safe_config.collection_name == "normal_test"
assert safe_config.collection_name == "normal_test"
assert safe_config.host == "localhost"
assert safe_config.port == 9200
assert safe_config.embedding_model_dims == 1536
@@ -339,13 +439,15 @@ def test_safe_deepcopy_config_normal_configs(mock_sqlite, mock_llm_factory, mock
assert safe_config.verify_certs is True
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_memory_initialization_opensearch_aws_auth(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
@patch("mem0.utils.factory.EmbedderFactory.create")
@patch("mem0.utils.factory.VectorStoreFactory.create")
@patch("mem0.utils.factory.LlmFactory.create")
@patch("mem0.memory.storage.SQLiteManager")
def test_memory_initialization_opensearch_aws_auth(
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
):
"""Test that Memory initialization works with OpenSearch configs containing AWS auth."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
+16 -16
View File
@@ -432,7 +432,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -481,7 +481,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1031,7 +1031,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.list(limit=2)
results = pgvector.list(top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1079,7 +1079,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.list(limit=2)
results = pgvector.list(top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1127,7 +1127,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2, filters=filters)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2, filters=filters)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1177,7 +1177,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2, filters=filters)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2, filters=filters)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1227,7 +1227,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice"}
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2, filters=filters)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2, filters=filters)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1275,7 +1275,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice"}
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2, filters=filters)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2, filters=filters)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1323,7 +1323,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2, filters=None)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2, filters=None)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1372,7 +1372,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.search("test query", [0.1, 0.2, 0.3], limit=2, filters=None)
results = pgvector.search("test query", [0.1, 0.2, 0.3], top_k=2, filters=None)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1421,7 +1421,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice", "agent_id": "agent1"}
results = pgvector.list(filters=filters, limit=2)
results = pgvector.list(filters=filters, top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1470,7 +1470,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice", "agent_id": "agent1"}
results = pgvector.list(filters=filters, limit=2)
results = pgvector.list(filters=filters, top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1519,7 +1519,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice"}
results = pgvector.list(filters=filters, limit=2)
results = pgvector.list(filters=filters, top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1567,7 +1567,7 @@ class TestPGVector(unittest.TestCase):
)
filters = {"user_id": "alice"}
results = pgvector.list(filters=filters, limit=2)
results = pgvector.list(filters=filters, top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1615,7 +1615,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.list(filters=None, limit=2)
results = pgvector.list(filters=None, top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
@@ -1663,7 +1663,7 @@ class TestPGVector(unittest.TestCase):
maxconn=4
)
results = pgvector.list(filters=None, limit=2)
results = pgvector.list(filters=None, top_k=2)
# Verify the _get_cursor context manager was called
mock_get_cursor.assert_called()
+1 -1
View File
@@ -80,7 +80,7 @@ def test_insert_vectors(pinecone_db):
def test_search_vectors(pinecone_db):
pinecone_db.index.query.return_value.matches = [{"id": "id1", "score": 0.9, "metadata": {"name": "vector1"}}]
results = pinecone_db.search("test query", [0.1] * 128, limit=1)
results = pinecone_db.search("test query", [0.1] * 128, top_k=1)
pinecone_db.index.query.assert_called_with(
vector=[0.1] * 128,
top_k=1,
+633 -8
View File
@@ -1,13 +1,23 @@
import os
import tempfile
import unittest
import uuid
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from qdrant_client import QdrantClient
from qdrant_client.models import (
DatetimeRange,
Distance,
FieldCondition,
Filter,
MatchAny,
MatchExcept,
MatchText,
MatchValue,
PointIdsList,
PointStruct,
PointVectors,
Range,
VectorParams,
)
@@ -25,6 +35,23 @@ class TestQdrant(unittest.TestCase):
on_disk=True,
)
def test_local_path_on_disk_false_preserves_existing_directory(self):
"""#4473: local path must not be removed when on_disk is False."""
with tempfile.TemporaryDirectory() as tmp:
sentinel = os.path.join(tmp, "sentinel")
with open(sentinel, "w", encoding="utf-8") as f:
f.write("keep")
mock_client = MagicMock()
mock_client.get_collections.return_value = MagicMock(collections=[])
with patch("mem0.vector_stores.qdrant.QdrantClient", return_value=mock_client):
Qdrant(
collection_name="c",
embedding_model_dims=128,
path=tmp,
on_disk=False,
)
self.assertTrue(os.path.isfile(sentinel))
def test_create_col(self):
self.client_mock.get_collections.return_value = MagicMock(collections=[])
@@ -57,7 +84,7 @@ class TestQdrant(unittest.TestCase):
mock_point = MagicMock(id=str(uuid.uuid4()), score=0.95, payload={"key": "value"})
self.client_mock.query_points.return_value = MagicMock(points=[mock_point])
results = self.qdrant.search(query="", vectors=vectors, limit=1)
results = self.qdrant.search(query="", vectors=vectors, top_k=1)
self.client_mock.query_points.assert_called_once_with(
collection_name="test_collection",
@@ -81,7 +108,7 @@ class TestQdrant(unittest.TestCase):
self.client_mock.query_points.return_value = MagicMock(points=[mock_point])
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = self.qdrant.search(query="", vectors=vectors, limit=1, filters=filters)
results = self.qdrant.search(query="", vectors=vectors, top_k=1, filters=filters)
# Verify that _create_filter was called and query_filter was passed
self.client_mock.query_points.assert_called_once()
@@ -111,7 +138,7 @@ class TestQdrant(unittest.TestCase):
self.client_mock.query_points.return_value = MagicMock(points=[mock_point])
filters = {"user_id": "alice"}
results = self.qdrant.search(query="", vectors=vectors, limit=1, filters=filters)
results = self.qdrant.search(query="", vectors=vectors, top_k=1, filters=filters)
# Verify that a Filter object was created with single condition
call_args = self.client_mock.query_points.call_args[1]
@@ -128,7 +155,7 @@ class TestQdrant(unittest.TestCase):
mock_point = MagicMock(id=str(uuid.uuid4()), score=0.95, payload={"key": "value"})
self.client_mock.query_points.return_value = MagicMock(points=[mock_point])
results = self.qdrant.search(query="", vectors=vectors, limit=1, filters=None)
results = self.qdrant.search(query="", vectors=vectors, top_k=1, filters=None)
call_args = self.client_mock.query_points.call_args[1]
self.assertIsNone(call_args["query_filter"])
@@ -207,6 +234,43 @@ class TestQdrant(unittest.TestCase):
self.assertEqual(point.vector, updated_vector)
self.assertEqual(point.payload, updated_payload)
def test_update_with_none_vector_uses_set_payload(self):
"""Test that update with vector=None uses set_payload instead of upsert (fixes #3708)."""
vector_id = str(uuid.uuid4())
updated_payload = {"key": "updated_value"}
self.qdrant.update(vector_id=vector_id, vector=None, payload=updated_payload)
self.client_mock.upsert.assert_not_called()
self.client_mock.set_payload.assert_called_once_with(
collection_name="test_collection",
payload=updated_payload,
points=[vector_id],
)
def test_update_with_none_payload_uses_update_vectors(self):
"""Test that update with payload=None uses update_vectors instead of upsert."""
vector_id = str(uuid.uuid4())
updated_vector = [0.2, 0.3]
self.qdrant.update(vector_id=vector_id, vector=updated_vector, payload=None)
self.client_mock.upsert.assert_not_called()
self.client_mock.update_vectors.assert_called_once_with(
collection_name="test_collection",
points=[PointVectors(id=vector_id, vector=updated_vector)],
)
def test_update_with_both_none_is_noop(self):
"""Test that update with both vector=None and payload=None is a no-op."""
vector_id = str(uuid.uuid4())
self.qdrant.update(vector_id=vector_id, vector=None, payload=None)
self.client_mock.upsert.assert_not_called()
self.client_mock.set_payload.assert_not_called()
self.client_mock.update_vectors.assert_not_called()
def test_get(self):
vector_id = str(uuid.uuid4())
self.client_mock.retrieve.return_value = [{"id": vector_id, "payload": {"key": "value"}}]
@@ -234,7 +298,7 @@ class TestQdrant(unittest.TestCase):
self.client_mock.scroll.return_value = [mock_point]
filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}
results = self.qdrant.list(filters=filters, limit=10)
results = self.qdrant.list(filters=filters, top_k=10)
# Verify that _create_filter was called and scroll_filter was passed
self.client_mock.scroll.assert_called_once()
@@ -263,7 +327,7 @@ class TestQdrant(unittest.TestCase):
self.client_mock.scroll.return_value = [mock_point]
filters = {"user_id": "alice"}
results = self.qdrant.list(filters=filters, limit=10)
results = self.qdrant.list(filters=filters, top_k=10)
# Verify that a Filter object was created with single condition
call_args = self.client_mock.scroll.call_args[1]
@@ -280,7 +344,7 @@ class TestQdrant(unittest.TestCase):
mock_point = MagicMock(id=str(uuid.uuid4()), score=0.95, payload={"key": "value"})
self.client_mock.scroll.return_value = [mock_point]
results = self.qdrant.list(filters=None, limit=10)
results = self.qdrant.list(filters=None, top_k=10)
call_args = self.client_mock.scroll.call_args[1]
self.assertIsNone(call_args["scroll_filter"])
@@ -298,3 +362,564 @@ class TestQdrant(unittest.TestCase):
def tearDown(self):
del self.qdrant
class TestQdrantEnhancedFilters(unittest.TestCase):
"""Tests for enhanced metadata filtering operators (issue #3975)."""
def setUp(self):
self.client_mock = MagicMock(spec=QdrantClient)
self.qdrant = Qdrant(
collection_name="test_collection",
embedding_model_dims=128,
client=self.client_mock,
)
# ------------------------------------------------------------------ #
# _build_field_condition #
# ------------------------------------------------------------------ #
def test_simple_equality(self):
"""Plain value maps to MatchValue."""
cond = self.qdrant._build_field_condition("category", "programming")
self.assertIsInstance(cond, FieldCondition)
self.assertEqual(cond.key, "category")
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "programming")
def test_eq_operator(self):
"""{\"eq\": v} maps to MatchValue."""
cond = self.qdrant._build_field_condition("category", {"eq": "programming"})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "programming")
def test_ne_operator(self):
"""{\"ne\": v} maps to MatchExcept with a single-element list."""
cond = self.qdrant._build_field_condition("category", {"ne": "spam"})
self.assertIsInstance(cond.match, MatchExcept)
self.assertIn("spam", getattr(cond.match, "except_", None) or getattr(cond.match, "except"))
def test_in_operator(self):
"""{\"in\": [...]} maps to MatchAny."""
cond = self.qdrant._build_field_condition("category", {"in": ["a", "b", "c"]})
self.assertIsInstance(cond.match, MatchAny)
self.assertEqual(cond.match.any, ["a", "b", "c"])
def test_nin_operator(self):
"""{\"nin\": [...]} maps to MatchExcept."""
cond = self.qdrant._build_field_condition("status", {"nin": ["deleted", "banned"]})
self.assertIsInstance(cond.match, MatchExcept)
excluded = getattr(cond.match, "except_", None) or getattr(cond.match, "except")
self.assertIn("deleted", excluded)
self.assertIn("banned", excluded)
def test_contains_operator(self):
"""{\"contains\": x} maps to MatchText."""
cond = self.qdrant._build_field_condition("bio", {"contains": "python"})
self.assertIsInstance(cond.match, MatchText)
self.assertEqual(cond.match.text, "python")
def test_icontains_operator(self):
"""{\"icontains\": x} maps to MatchText (same as contains for Qdrant)."""
cond = self.qdrant._build_field_condition("bio", {"icontains": "Python"})
self.assertIsInstance(cond.match, MatchText)
self.assertEqual(cond.match.text, "Python")
def test_gt_operator(self):
"""{\"gt\": v} maps to Range with gt only."""
cond = self.qdrant._build_field_condition("priority", {"gt": 5})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gt, 5)
self.assertIsNone(cond.range.gte)
self.assertIsNone(cond.range.lt)
self.assertIsNone(cond.range.lte)
def test_gte_operator(self):
"""{\"gte\": v} maps to Range with gte only."""
cond = self.qdrant._build_field_condition("priority", {"gte": 5})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gte, 5)
self.assertIsNone(cond.range.gt)
def test_lt_operator(self):
"""{\"lt\": v} maps to Range with lt only."""
cond = self.qdrant._build_field_condition("priority", {"lt": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.lt, 10)
def test_lte_operator(self):
"""{\"lte\": v} maps to Range with lte only."""
cond = self.qdrant._build_field_condition("priority", {"lte": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.lte, 10)
def test_range_gte_lte(self):
"""Combined {\"gte\": x, \"lte\": y} maps to Range with both bounds."""
cond = self.qdrant._build_field_condition("priority", {"gte": 5, "lte": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gte, 5)
self.assertEqual(cond.range.lte, 10)
def test_range_gt_lt(self):
"""Open interval {\"gt\": x, \"lt\": y} maps to Range with both bounds."""
cond = self.qdrant._build_field_condition("score", {"gt": 0.5, "lt": 0.9})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gt, 0.5)
self.assertEqual(cond.range.lt, 0.9)
# ------------------------------------------------------------------ #
# _create_filter — comparison and list operators #
# ------------------------------------------------------------------ #
def test_create_filter_eq(self):
result = self.qdrant._create_filter({"category": {"eq": "programming"}})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertIsInstance(result.must[0].match, MatchValue)
def test_create_filter_ne(self):
result = self.qdrant._create_filter({"category": {"ne": "spam"}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchExcept)
def test_create_filter_in(self):
result = self.qdrant._create_filter({"category": {"in": ["prog", "data"]}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchAny)
self.assertEqual(result.must[0].match.any, ["prog", "data"])
def test_create_filter_nin(self):
result = self.qdrant._create_filter({"status": {"nin": ["deleted", "banned"]}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchExcept)
def test_create_filter_gt(self):
result = self.qdrant._create_filter({"priority": {"gt": 7}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].range, Range)
self.assertEqual(result.must[0].range.gt, 7)
def test_create_filter_gte_lte_range(self):
result = self.qdrant._create_filter({"priority": {"gte": 5, "lte": 9}})
self.assertIsInstance(result, Filter)
r = result.must[0].range
self.assertEqual(r.gte, 5)
self.assertEqual(r.lte, 9)
def test_create_filter_contains(self):
result = self.qdrant._create_filter({"bio": {"contains": "python"}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchText)
# ------------------------------------------------------------------ #
# _create_filter — logical operators #
# ------------------------------------------------------------------ #
def test_and_operator(self):
"""AND populates Filter.must with nested Filter objects."""
filters = {"AND": [{"category": "programming"}, {"priority": 10}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 2)
# Each item is a Filter that wraps a single FieldCondition
for item in result.must:
self.assertIsInstance(item, Filter)
def test_or_operator(self):
"""OR populates Filter.should with nested Filter objects."""
filters = {"OR": [{"category": "programming"}, {"category": "data"}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.should), 2)
self.assertIsNone(result.must)
def test_not_operator(self):
"""NOT populates Filter.must_not with nested Filter objects."""
filters = {"NOT": [{"category": "spam"}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must_not), 1)
self.assertIsNone(result.must)
self.assertIsNone(result.should)
def test_mixed_field_and_and(self):
"""Top-level field conditions and AND can coexist in must."""
filters = {
"user_id": "alice",
"AND": [{"priority": {"gte": 5}}, {"category": "programming"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
# user_id FieldCondition + 2 nested AND Filters = 3 items in must
self.assertEqual(len(result.must), 3)
def test_nested_and_or(self):
"""AND containing an OR sub-condition produces correct nesting."""
filters = {
"AND": [
{"OR": [{"category": "prog"}, {"category": "data"}]},
{"priority": {"gt": 3}},
]
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 2)
# First item is a Filter with should (the OR)
or_filter = result.must[0]
self.assertIsInstance(or_filter, Filter)
self.assertEqual(len(or_filter.should), 2)
# ------------------------------------------------------------------ #
# Edge cases #
# ------------------------------------------------------------------ #
def test_empty_filter_returns_none(self):
self.assertIsNone(self.qdrant._create_filter({}))
self.assertIsNone(self.qdrant._create_filter(None))
def test_backward_compat_simple_equality(self):
"""Plain scalar equality still works as before."""
result = self.qdrant._create_filter({"user_id": "alice", "run_id": "r1"})
self.assertIsInstance(result, Filter)
keys = [c.key for c in result.must]
self.assertIn("user_id", keys)
self.assertIn("run_id", keys)
def test_backward_compat_gte_lte_range(self):
"""Original gte+lte range filter still produces a Range condition."""
result = self.qdrant._create_filter({"count": {"gte": 5, "lte": 10}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].range, Range)
self.assertEqual(result.must[0].range.gte, 5)
self.assertEqual(result.must[0].range.lte, 10)
def test_unknown_operator_raises_error(self):
"""Unknown operator dict should raise ValueError, not ValidationError."""
with self.assertRaises(ValueError) as ctx:
self.qdrant._build_field_condition("field", {"unknown_op": "foo"})
self.assertIn("Unsupported", str(ctx.exception))
def test_mixed_range_and_non_range_raises_error(self):
"""Mixing range ops with non-range ops should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("priority", {"gte": 5, "ne": 10})
def test_mixed_range_and_eq_raises_error(self):
"""Mixing range ops with eq should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("score", {"gt": 0.5, "eq": 1.0})
def test_wildcard_returns_none(self):
"""Wildcard '*' should return None (skip filter — match any)."""
result = self.qdrant._build_field_condition("category", "*")
self.assertIsNone(result)
def test_create_filter_with_wildcard_skips_it(self):
"""Wildcard fields should be skipped in the final filter."""
result = self.qdrant._create_filter({"category": "*", "user_id": "alice"})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertEqual(result.must[0].key, "user_id")
def test_create_filter_only_wildcard_returns_none(self):
"""Filter with only wildcard should return None."""
result = self.qdrant._create_filter({"category": "*"})
self.assertIsNone(result)
def test_and_with_non_list_raises_error(self):
"""AND with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"AND": "not_a_list"})
def test_or_with_non_list_raises_error(self):
"""OR with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"OR": {"category": "work"}})
def test_not_with_non_list_raises_error(self):
"""NOT with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"NOT": "invalid"})
def test_empty_and_list(self):
"""AND with empty list should return None."""
result = self.qdrant._create_filter({"AND": []})
self.assertIsNone(result)
def test_empty_or_list(self):
"""OR with empty list should return None."""
result = self.qdrant._create_filter({"OR": []})
self.assertIsNone(result)
def test_empty_not_list(self):
"""NOT with empty list should return None."""
result = self.qdrant._create_filter({"NOT": []})
self.assertIsNone(result)
def test_deeply_nested_logical(self):
"""3-level nesting: AND > OR > NOT."""
filters = {
"AND": [
{
"OR": [
{"category": "work"},
{"category": "personal"}
]
},
{"priority": {"gte": 5}},
{
"NOT": [
{"status": "archived"}
]
}
]
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 3)
def test_boolean_equality(self):
"""Boolean values should work with MatchValue."""
cond = self.qdrant._build_field_condition("active", True)
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, True)
def test_integer_equality(self):
"""Integer values should work with MatchValue."""
cond = self.qdrant._build_field_condition("count", 42)
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, 42)
def test_eq_with_boolean(self):
"""eq operator with boolean should work."""
cond = self.qdrant._build_field_condition("active", {"eq": False})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, False)
def test_in_with_integers(self):
"""in operator with integer list should use MatchAny."""
cond = self.qdrant._build_field_condition("priority", {"in": [1, 2, 3]})
self.assertIsInstance(cond.match, MatchAny)
self.assertEqual(cond.match.any, [1, 2, 3])
def test_wildcard_inside_and(self):
"""Wildcard inside AND should be skipped, other conditions preserved."""
result = self.qdrant._create_filter({
"AND": [{"category": "*"}, {"user_id": "alice"}]
})
self.assertIsInstance(result, Filter)
# AND produces nested Filters; the wildcard sub-filter returns None and is skipped
# Only the user_id sub-filter remains
self.assertEqual(len(result.must), 1)
def test_list_value_treated_as_match_any(self):
"""List value shorthand should be treated as in-operator (MatchAny)."""
cond = self.qdrant._build_field_condition("tags", ["a", "b", "c"])
self.assertIsInstance(cond.match, MatchAny)
self.assertEqual(cond.match.any, ["a", "b", "c"])
def test_list_value_in_create_filter(self):
"""List value shorthand should work through _create_filter too."""
result = self.qdrant._create_filter({"tags": ["python", "rust"]})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertIsInstance(result.must[0].match, MatchAny)
def test_non_dict_item_in_and_raises_error(self):
"""Non-dict item inside AND list should raise clear ValueError."""
with self.assertRaises(ValueError) as ctx:
self.qdrant._create_filter({"AND": ["not_a_dict", {"field": "val"}]})
self.assertIn("index 0", str(ctx.exception))
self.assertIn("must be a dict", str(ctx.exception))
def test_non_dict_item_in_or_raises_error(self):
"""Non-dict item inside OR list should raise clear ValueError."""
with self.assertRaises(ValueError) as ctx:
self.qdrant._create_filter({"OR": [{"field": "val"}, 42]})
self.assertIn("index 1", str(ctx.exception))
def test_empty_dict_value_raises_error(self):
"""Empty dict as filter value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("field", {})
def test_ne_alone_does_not_trigger_mixed_error(self):
"""ne as sole operator should NOT trigger mixed-operator error (regression guard)."""
cond = self.qdrant._build_field_condition("status", {"ne": "deleted"})
self.assertIsInstance(cond.match, MatchExcept)
def test_eq_with_literal_star(self):
"""eq operator with literal '*' should match the string '*', not wildcard."""
cond = self.qdrant._build_field_condition("category", {"eq": "*"})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "*")
# ------------------------------------------------------------------ #
# $or / $not normalization (Memory._process_metadata_filters injects #
# these keys alongside the original OR/NOT) #
# ------------------------------------------------------------------ #
def test_dollar_or_handled_as_or(self):
"""$or injected by Memory middleware should be treated as OR."""
filters = {
"user_id": "alice",
"$or": [{"category": "programming"}, {"category": "data"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.should)
self.assertEqual(len(result.should), 2)
def test_dollar_not_handled_as_not(self):
"""$not injected by Memory middleware should be treated as NOT."""
filters = {
"user_id": "alice",
"$not": [{"category": "spam"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.must_not)
self.assertEqual(len(result.must_not), 1)
def test_memory_search_or_shape(self):
"""Simulate exact shape Memory.search() sends for OR filters.
effective_filters keeps the original OR key (via deepcopy of
input_filters) and _process_metadata_filters adds $or with the
same content. _create_filter should deduplicate so only the
first occurrence (OR) is used — exactly 2 should entries.
"""
filters = {
"OR": [{"category": "programming"}, {"category": "data"}],
"user_id": "test_user",
"$or": [{"category": "programming"}, {"category": "data"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.should)
# Deduplicated: OR wins, $or is skipped — exactly 2 entries
self.assertEqual(len(result.should), 2)
def test_memory_search_not_shape(self):
"""Simulate exact shape Memory.search() sends for NOT filters.
Same deduplication as OR: NOT wins, $not is skipped.
"""
filters = {
"NOT": [{"category": "spam"}],
"user_id": "test_user",
"$not": [{"category": "spam"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.must_not)
# Deduplicated: NOT wins, $not is skipped — exactly 1 entry
self.assertEqual(len(result.must_not), 1)
class TestQdrantDatetimeRangeFilters(unittest.TestCase):
"""Tests for datetime range filter support (issue #4591)."""
def setUp(self):
self.client_mock = MagicMock(spec=QdrantClient)
self.qdrant = Qdrant(
collection_name="test_collection",
embedding_model_dims=128,
client=self.client_mock,
)
def test_iso_datetime_gte_lte_uses_datetime_range(self):
"""ISO datetime strings in range filters should use DatetimeRange."""
cond = self.qdrant._build_field_condition(
"created_at", {"gte": "2025-01-01T00:00:00Z", "lte": "2025-12-31T23:59:59Z"}
)
self.assertIsInstance(cond, FieldCondition)
self.assertIsInstance(cond.range, DatetimeRange)
self.assertIsNotNone(cond.range.gte)
self.assertIsNotNone(cond.range.lte)
def test_iso_date_only_uses_datetime_range(self):
"""Date-only strings (YYYY-MM-DD) should also use DatetimeRange."""
cond = self.qdrant._build_field_condition(
"created_at", {"gte": "2025-01-01", "lt": "2025-02-01"}
)
self.assertIsInstance(cond.range, DatetimeRange)
def test_iso_datetime_with_offset_uses_datetime_range(self):
"""Datetime with timezone offset should use DatetimeRange."""
cond = self.qdrant._build_field_condition(
"updated_at", {"gt": "2025-06-15T10:30:00+05:30"}
)
self.assertIsInstance(cond.range, DatetimeRange)
def test_numeric_range_still_uses_range(self):
"""Numeric values should still use Range (not DatetimeRange)."""
cond = self.qdrant._build_field_condition(
"priority", {"gte": 5, "lte": 10}
)
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gte, 5)
self.assertEqual(cond.range.lte, 10)
def test_float_range_still_uses_range(self):
"""Float values should still use Range."""
cond = self.qdrant._build_field_condition(
"score", {"gt": 0.5, "lt": 0.9}
)
self.assertIsInstance(cond.range, Range)
def test_datetime_range_via_create_filter(self):
"""DatetimeRange should work through _create_filter."""
result = self.qdrant._create_filter(
{"created_at": {"gte": "2025-01-01T00:00:00Z", "lte": "2025-12-31T23:59:59Z"}}
)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertIsInstance(result.must[0].range, DatetimeRange)
def test_malformed_datetime_raises_with_field_context(self):
"""Malformed date-like string should raise ValueError with field name."""
with self.assertRaises(ValueError) as ctx:
self.qdrant._build_field_condition(
"created_at", {"gte": "2025-13-45"}
)
self.assertIn("created_at", str(ctx.exception))
def test_mixed_datetime_and_numeric_raises_error(self):
"""Mixed datetime string + numeric value in same range should raise an error.
When not all values are datetime strings, _is_datetime_range returns False
and Range receives a string, causing a Pydantic ValidationError.
"""
from pydantic import ValidationError
with self.assertRaises(ValidationError):
self.qdrant._build_field_condition(
"field", {"gte": "2025-01-01", "lte": 100}
)
def test_iso_datetime_with_fractional_seconds(self):
"""Fractional seconds should use DatetimeRange."""
cond = self.qdrant._build_field_condition(
"created_at", {"gte": "2025-01-01T00:00:00.123456Z"}
)
self.assertIsInstance(cond.range, DatetimeRange)
def test_iso_datetime_space_separated(self):
"""Space-separated datetime should use DatetimeRange."""
cond = self.qdrant._build_field_condition(
"created_at", {"gte": "2025-01-01 10:30:00"}
)
self.assertIsInstance(cond.range, DatetimeRange)
def test_datetime_with_numeric_mixed_filters(self):
"""Datetime and numeric range filters can coexist in same query."""
result = self.qdrant._create_filter({
"created_at": {"gte": "2025-01-01"},
"priority": {"gte": 5},
})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 2)
types = {type(c.range) for c in result.must}
self.assertIn(DatetimeRange, types)
self.assertIn(Range, types)
+73
View File
@@ -0,0 +1,73 @@
"""Tests for Redis vector store update() — embedding corruption fix.
Regression tests for #4336: when update() is called with vector=None
(metadata-only update), np.array(None) silently creates a 4-byte scalar,
overwriting the real embedding. The fix skips the embedding field entirely
when vector is None.
"""
from datetime import datetime
from unittest.mock import MagicMock
import numpy as np
import pytz
def _make_redis_db():
"""Create a RedisDB instance with mocked internals, bypassing __init__
to avoid the redis module name collision with mem0.vector_stores.redis."""
from mem0.vector_stores.redis import RedisDB
db = RedisDB.__new__(RedisDB)
mock_index = MagicMock()
db.index = mock_index
db.schema = {"index": {"prefix": "mem0:test"}}
return db, mock_index
def test_update_with_none_vector_preserves_embedding():
"""update() with vector=None should not include embedding in the data."""
db, mock_index = _make_redis_db()
payload = {
"hash": "test_hash",
"data": "updated_data",
"created_at": datetime.now(pytz.timezone("UTC")).isoformat(),
"updated_at": datetime.now(pytz.timezone("UTC")).isoformat(),
"user_id": "test_user",
}
db.update(vector_id="test_id", vector=None, payload=payload)
mock_index.load.assert_called_once()
call_kwargs = mock_index.load.call_args
data_dict = call_kwargs[1]["data"][0] if "data" in call_kwargs[1] else call_kwargs[0][0][0]
assert "embedding" not in data_dict, (
"embedding should not be in data when vector is None"
)
assert data_dict["memory_id"] == "test_id"
def test_update_with_vector_includes_embedding():
"""update() with a real vector should include embedding in the data."""
db, mock_index = _make_redis_db()
vector = np.random.rand(1536).tolist()
payload = {
"hash": "test_hash",
"data": "updated_data",
"created_at": datetime.now(pytz.timezone("UTC")).isoformat(),
"updated_at": datetime.now(pytz.timezone("UTC")).isoformat(),
"user_id": "test_user",
}
db.update(vector_id="test_id", vector=vector, payload=payload)
mock_index.load.assert_called_once()
call_kwargs = mock_index.load.call_args
data_dict = call_kwargs[1]["data"][0] if "data" in call_kwargs[1] else call_kwargs[0][0][0]
assert "embedding" in data_dict, (
"embedding should be in data when vector is provided"
)
expected_bytes = np.array(vector, dtype=np.float32).tobytes()
assert data_dict["embedding"] == expected_bytes
+2 -2
View File
@@ -1,7 +1,7 @@
from mem0.configs.vector_stores.s3_vectors import S3VectorsConfig
import pytest
from botocore.exceptions import ClientError
from mem0.configs.vector_stores.s3_vectors import S3VectorsConfig
from mem0.memory.main import Memory
from mem0.vector_stores.s3_vectors import S3Vectors
@@ -152,7 +152,7 @@ def test_search(mock_boto_client):
embedding_model_dims=EMBEDDING_DIMS,
)
query_vector = [0.1, 0.2]
results = store.search(query="test", vectors=query_vector, limit=1)
results = store.search(query="test", vectors=query_vector, top_k=1)
mock_boto_client.query_vectors.assert_called_once()
assert len(results) == 1
+2 -2
View File
@@ -67,7 +67,7 @@ def test_search_vectors(supabase_instance, mock_collection):
vectors = [[0.1, 0.2, 0.3]]
filters = {"category": "test"}
results = supabase_instance.search(query="", vectors=vectors, limit=2, filters=filters)
results = supabase_instance.search(query="", vectors=vectors, top_k=2, filters=filters)
mock_collection.query.assert_called_once_with(
data=vectors, limit=2, filters={"category": {"$eq": "test"}}, include_metadata=True, include_value=True
@@ -118,7 +118,7 @@ def test_list_vectors(supabase_instance, mock_collection):
mock_collection.query.return_value = mock_query_results
mock_collection.fetch.return_value = mock_fetch_results
results = supabase_instance.list(limit=2, filters={"category": "test"})
results = supabase_instance.list(top_k=2, filters={"category": "test"})
assert len(results[0]) == 2
assert results[0][0].id == "id1"
+725
View File
@@ -0,0 +1,725 @@
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
pytest.importorskip("turbopuffer", reason="turbopuffer not installed")
from turbopuffer.types import Row
from mem0.vector_stores.turbopuffer import OutputData, TurbopufferDB
def _make_row(id, dist=None, vector=None, **attributes):
"""Helper to create a turbopuffer Row with extra attributes."""
data = {"id": id, "vector": vector}
if dist is not None:
data["$dist"] = dist
data.update(attributes)
return Row.model_validate(data)
@pytest.fixture
def mock_client():
with patch("mem0.vector_stores.turbopuffer.TurbopufferClient") as MockClient:
client_instance = MagicMock()
namespace_instance = MagicMock()
client_instance.namespace.return_value = namespace_instance
MockClient.return_value = client_instance
yield client_instance, namespace_instance
@pytest.fixture
def db(mock_client):
_, _ = mock_client
return TurbopufferDB(
collection_name="test_ns",
embedding_model_dims=4,
api_key="tpuf_test_key",
region="gcp-us-central1",
distance_metric="cosine_distance",
batch_size=2,
)
# ── Initialization ──────────────────────────────────────────────────
class TestInit:
def test_init_with_api_key(self, mock_client):
client_instance, namespace_instance = mock_client
db = TurbopufferDB(
collection_name="my_ns",
embedding_model_dims=128,
api_key="tpuf_key",
region="gcp-us-central1",
)
assert db.collection_name == "my_ns"
assert db.embedding_model_dims == 128
assert db.distance_metric == "cosine_distance"
assert db.batch_size == 100
client_instance.namespace.assert_called_with("my_ns")
def test_init_with_env_var(self, mock_client):
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "tpuf_env_key"}):
db = TurbopufferDB(
collection_name="test",
embedding_model_dims=4,
region="gcp-us-central1",
)
assert db.collection_name == "test"
def test_init_without_api_key_raises(self, mock_client):
with patch.dict("os.environ", {}, clear=True):
# Remove TURBOPUFFER_API_KEY if it exists
import os
os.environ.pop("TURBOPUFFER_API_KEY", None)
with pytest.raises(ValueError, match="API key must be provided"):
TurbopufferDB(
collection_name="test",
embedding_model_dims=4,
region="gcp-us-central1",
)
def test_init_with_extra_params(self, mock_client):
client_instance, _ = mock_client
with patch("mem0.vector_stores.turbopuffer.TurbopufferClient") as MockClient:
MockClient.return_value = client_instance
TurbopufferDB(
collection_name="test",
embedding_model_dims=4,
api_key="key",
region="aws-us-west-2",
extra_params={"compression": True},
)
MockClient.assert_called_with(
api_key="key",
region="aws-us-west-2",
compression=True,
)
def test_init_default_region(self, mock_client):
client_instance, _ = mock_client
with patch("mem0.vector_stores.turbopuffer.TurbopufferClient") as MockClient:
MockClient.return_value = client_instance
TurbopufferDB(
collection_name="test",
embedding_model_dims=4,
api_key="key",
)
MockClient.assert_called_with(
api_key="key",
region="gcp-us-central1",
)
# ── create_col ───────────────────────────────────────────────────────
class TestCreateCol:
def test_create_col_is_noop(self, db):
# Should not raise or call anything
result = db.create_col()
assert result is None
def test_create_col_with_args_is_noop(self, db):
result = db.create_col(name="x", vector_size=128, distance="cosine")
assert result is None
# ── insert ───────────────────────────────────────────────────────────
class TestInsert:
def test_insert_with_ids_and_payloads(self, db):
vectors = [[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]]
payloads = [{"data": "hello", "user_id": "u1"}, {"data": "world", "user_id": "u2"}]
ids = ["id1", "id2"]
db.insert(vectors, payloads, ids)
db.namespace.write.assert_called_once()
call_kwargs = db.namespace.write.call_args[1]
rows = call_kwargs["upsert_rows"]
assert len(rows) == 2
assert rows[0]["id"] == "id1"
assert rows[0]["vector"] == [0.1, 0.2, 0.3, 0.4]
assert rows[0]["data"] == "hello"
assert rows[0]["user_id"] == "u1"
assert rows[1]["id"] == "id2"
assert call_kwargs["distance_metric"] == "cosine_distance"
def test_insert_without_ids_generates_ids(self, db):
vectors = [[0.1, 0.2, 0.3, 0.4]]
db.insert(vectors)
db.namespace.write.assert_called_once()
call_kwargs = db.namespace.write.call_args[1]
assert call_kwargs["upsert_rows"][0]["id"] == "0"
def test_insert_without_payloads(self, db):
vectors = [[0.1, 0.2, 0.3, 0.4]]
ids = ["id1"]
db.insert(vectors, ids=ids)
call_kwargs = db.namespace.write.call_args[1]
row = call_kwargs["upsert_rows"][0]
assert row["id"] == "id1"
assert row["vector"] == [0.1, 0.2, 0.3, 0.4]
assert len(row) == 2 # only id and vector
def test_insert_payload_does_not_overwrite_id_or_vector(self, db):
"""Payload with 'id' or 'vector' keys must not overwrite the actual values."""
vectors = [[0.1, 0.2, 0.3, 0.4]]
payloads = [{"id": "fake_id", "vector": "fake_vector", "data": "hello"}]
ids = ["real_id"]
db.insert(vectors, payloads, ids)
call_kwargs = db.namespace.write.call_args[1]
row = call_kwargs["upsert_rows"][0]
assert row["id"] == "real_id"
assert row["vector"] == [0.1, 0.2, 0.3, 0.4]
assert row["data"] == "hello"
def test_insert_batching(self, db):
"""batch_size=2, so 3 vectors should produce 2 write calls."""
vectors = [[0.1] * 4, [0.2] * 4, [0.3] * 4]
ids = ["a", "b", "c"]
db.insert(vectors, ids=ids)
assert db.namespace.write.call_count == 2
first_call = db.namespace.write.call_args_list[0][1]
second_call = db.namespace.write.call_args_list[1][1]
assert len(first_call["upsert_rows"]) == 2
assert len(second_call["upsert_rows"]) == 1
# ── _parse_output ────────────────────────────────────────────────────
class TestParseOutput:
def test_parse_rows_with_dist_and_attributes(self, db):
rows = [
_make_row("id1", dist=0.1, data="hello", user_id="u1"),
_make_row("id2", dist=0.3, data="world", user_id="u2"),
]
results = db._parse_output(rows)
assert len(results) == 2
assert results[0].id == "id1"
assert results[0].score == pytest.approx(0.9)
assert results[0].payload == {"data": "hello", "user_id": "u1"}
assert results[1].id == "id2"
assert results[1].score == pytest.approx(0.7)
assert results[1].payload == {"data": "world", "user_id": "u2"}
def test_parse_rows_without_dist(self, db):
rows = [_make_row("id1", data="hello")]
results = db._parse_output(rows)
assert results[0].score is None
assert results[0].payload == {"data": "hello"}
def test_parse_rows_strips_vector_and_id(self, db):
rows = [_make_row("id1", dist=0.0, vector=[1.0, 2.0, 3.0, 4.0], data="test")]
results = db._parse_output(rows)
assert "vector" not in results[0].payload
assert "id" not in results[0].payload
assert "$dist" not in results[0].payload
assert results[0].id == "id1"
def test_parse_empty_rows(self, db):
assert db._parse_output([]) == []
# ── _convert_filters ─────────────────────────────────────────────────
class TestConvertFilters:
def test_none_filters(self, db):
assert db._convert_filters(None) is None
def test_empty_filters(self, db):
assert db._convert_filters({}) is None
def test_single_eq_filter(self, db):
result = db._convert_filters({"user_id": "u1"})
assert result == ("user_id", "Eq", "u1")
def test_multiple_eq_filters(self, db):
result = db._convert_filters({"user_id": "u1", "agent_id": "a1"})
assert result[0] == "And"
conditions = result[1]
assert ("user_id", "Eq", "u1") in conditions
assert ("agent_id", "Eq", "a1") in conditions
def test_range_filter_gte_lte(self, db):
result = db._convert_filters({"score": {"gte": 0.5, "lte": 1.0}})
assert result[0] == "And"
conditions = result[1]
assert ("score", "Gte", 0.5) in conditions
assert ("score", "Lte", 1.0) in conditions
def test_range_filter_gte_only(self, db):
result = db._convert_filters({"score": {"gte": 0.5}})
assert result == ("score", "Gte", 0.5)
def test_range_filter_lte_only(self, db):
result = db._convert_filters({"score": {"lte": 1.0}})
assert result == ("score", "Lte", 1.0)
def test_mixed_eq_and_range_filters(self, db):
result = db._convert_filters({"user_id": "u1", "score": {"gte": 0.5}})
assert result[0] == "And"
conditions = result[1]
assert ("user_id", "Eq", "u1") in conditions
assert ("score", "Gte", 0.5) in conditions
# ── search ───────────────────────────────────────────────────────────
class TestSearch:
def test_search_basic(self, db):
mock_response = MagicMock()
mock_response.rows = [
_make_row("id1", dist=0.1, data="hello"),
_make_row("id2", dist=0.2, data="world"),
]
db.namespace.query.return_value = mock_response
results = db.search("test query", [0.1, 0.2, 0.3, 0.4], top_k=2)
db.namespace.query.assert_called_once_with(
rank_by=("vector", "ANN", [0.1, 0.2, 0.3, 0.4]),
top_k=2,
include_attributes=True,
)
assert len(results) == 2
assert results[0].id == "id1"
assert results[0].score == pytest.approx(0.9)
def test_search_with_filters(self, db):
mock_response = MagicMock()
mock_response.rows = [_make_row("id1", dist=0.1, data="hello")]
db.namespace.query.return_value = mock_response
results = db.search(
"query", [0.1, 0.2, 0.3, 0.4], top_k=5, filters={"user_id": "u1"}
)
call_kwargs = db.namespace.query.call_args[1]
assert call_kwargs["filters"] == ("user_id", "Eq", "u1")
assert len(results) == 1
def test_search_no_results(self, db):
mock_response = MagicMock()
mock_response.rows = None
db.namespace.query.return_value = mock_response
results = db.search("query", [0.1, 0.2, 0.3, 0.4])
assert results == []
def test_search_empty_rows(self, db):
mock_response = MagicMock()
mock_response.rows = []
db.namespace.query.return_value = mock_response
results = db.search("query", [0.1, 0.2, 0.3, 0.4])
assert results == []
# ── delete ───────────────────────────────────────────────────────────
class TestDelete:
def test_delete_by_string_id(self, db):
db.delete("id1")
db.namespace.write.assert_called_once_with(deletes=["id1"])
def test_delete_by_int_id(self, db):
db.delete(123)
db.namespace.write.assert_called_once_with(deletes=["123"])
# ── update ───────────────────────────────────────────────────────────
class TestUpdate:
def test_update_with_vector_and_payload(self, db):
db.update("id1", vector=[0.5, 0.6, 0.7, 0.8], payload={"data": "updated"})
db.namespace.write.assert_called_once()
call_kwargs = db.namespace.write.call_args[1]
row = call_kwargs["upsert_rows"][0]
assert row["id"] == "id1"
assert row["vector"] == [0.5, 0.6, 0.7, 0.8]
assert row["data"] == "updated"
assert call_kwargs["distance_metric"] == "cosine_distance"
def test_update_vector_only(self, db):
db.update("id1", vector=[0.5, 0.6, 0.7, 0.8])
call_kwargs = db.namespace.write.call_args[1]
row = call_kwargs["upsert_rows"][0]
assert row["id"] == "id1"
assert row["vector"] == [0.5, 0.6, 0.7, 0.8]
assert "data" not in row
def test_update_payload_only_uses_patch(self, db):
"""Payload-only updates should use patch_rows, not upsert_rows."""
db.update("id1", vector=None, payload={"data": "patched", "user_id": "u1"})
call_kwargs = db.namespace.write.call_args[1]
row = call_kwargs["patch_rows"][0]
assert row["id"] == "id1"
assert row["data"] == "patched"
assert row["user_id"] == "u1"
assert "upsert_rows" not in call_kwargs
def test_update_payload_does_not_overwrite_id(self, db):
"""Payload with an 'id' key must not overwrite the actual vector ID."""
db.update("real_id", vector=None, payload={"id": "fake_id", "data": "test"})
call_kwargs = db.namespace.write.call_args[1]
row = call_kwargs["patch_rows"][0]
assert row["id"] == "real_id"
def test_update_nothing(self, db):
"""Neither vector nor payload: should not call write."""
db.update("id1")
db.namespace.write.assert_not_called()
# ── get ──────────────────────────────────────────────────────────────
class TestGet:
def test_get_found(self, db):
mock_response = MagicMock()
mock_response.rows = [_make_row("id1", dist=0.0, data="hello", user_id="u1")]
db.namespace.query.return_value = mock_response
result = db.get("id1")
db.namespace.query.assert_called_once_with(
top_k=1,
rank_by=("vector", "ANN", [0.0, 0.0, 0.0, 0.0]),
filters=("id", "Eq", "id1"),
include_attributes=True,
)
assert result is not None
assert result.id == "id1"
assert result.payload["data"] == "hello"
assert result.payload["user_id"] == "u1"
def test_get_not_found(self, db):
mock_response = MagicMock()
mock_response.rows = []
db.namespace.query.return_value = mock_response
result = db.get("nonexistent")
assert result is None
def test_get_none_rows(self, db):
mock_response = MagicMock()
mock_response.rows = None
db.namespace.query.return_value = mock_response
result = db.get("id1")
assert result is None
def test_get_handles_exception(self, db):
db.namespace.query.side_effect = Exception("API error")
result = db.get("id1")
assert result is None
# ── list_cols ────────────────────────────────────────────────────────
class TestListCols:
def test_list_cols(self, db):
ns1 = MagicMock()
ns1.id = "ns1"
ns2 = MagicMock()
ns2.id = "ns2"
db.client.namespaces.return_value = [ns1, ns2]
result = db.list_cols()
assert len(result) == 2
db.client.namespaces.assert_called_once()
# ── delete_col ───────────────────────────────────────────────────────
class TestDeleteCol:
def test_delete_col(self, db):
db.delete_col()
db.namespace.delete_all.assert_called_once()
def test_delete_col_handles_error(self, db):
db.namespace.delete_all.side_effect = Exception("API error")
# Should not raise
db.delete_col()
# ── col_info ─────────────────────────────────────────────────────────
class TestColInfo:
def test_col_info(self, db):
mock_metadata = MagicMock()
mock_metadata.approx_row_count = 42
mock_metadata.approx_logical_bytes = 1024
mock_metadata.created_at = datetime(2025, 1, 1)
mock_metadata.updated_at = datetime(2025, 6, 1)
db.namespace.metadata.return_value = mock_metadata
info = db.col_info()
assert info["name"] == "test_ns"
assert info["approx_row_count"] == 42
assert info["approx_logical_bytes"] == 1024
def test_col_info_handles_error(self, db):
db.namespace.metadata.side_effect = Exception("not found")
info = db.col_info()
assert info == {"name": "test_ns"}
# ── list ─────────────────────────────────────────────────────────────
class TestList:
def test_list_returns_wrapped_format(self, db):
"""list() must return [[results]] for compatibility with main.py."""
mock_response = MagicMock()
mock_response.rows = [
_make_row("id1", dist=0.1, data="hello"),
_make_row("id2", dist=0.2, data="world"),
]
db.namespace.query.return_value = mock_response
result = db.list()
# Must be wrapped: result[0] is the actual list
assert isinstance(result, list)
assert isinstance(result[0], list)
assert len(result[0]) == 2
assert result[0][0].id == "id1"
assert result[0][1].id == "id2"
def test_list_with_filters(self, db):
mock_response = MagicMock()
mock_response.rows = [_make_row("id1", dist=0.1, data="hello")]
db.namespace.query.return_value = mock_response
db.list(filters={"user_id": "u1"}, top_k=50)
call_kwargs = db.namespace.query.call_args[1]
assert call_kwargs["filters"] == ("user_id", "Eq", "u1")
assert call_kwargs["top_k"] == 50
def test_list_empty(self, db):
mock_response = MagicMock()
mock_response.rows = None
db.namespace.query.return_value = mock_response
result = db.list()
assert result == [[]]
def test_list_uses_zero_vector(self, db):
mock_response = MagicMock()
mock_response.rows = []
db.namespace.query.return_value = mock_response
db.list()
call_kwargs = db.namespace.query.call_args[1]
assert call_kwargs["rank_by"] == ("vector", "ANN", [0.0, 0.0, 0.0, 0.0])
def test_list_compatible_with_main_py_get_all(self, db):
"""Simulate how main.py _get_all_from_vector_store unwraps list()."""
mock_response = MagicMock()
mock_response.rows = [_make_row("id1", dist=0.1, data="hello")]
db.namespace.query.return_value = mock_response
memories_result = db.list(filters={"user_id": "u1"})
# Reproduce main.py unwrapping logic
first_element = memories_result[0]
if isinstance(first_element, (list, tuple)):
actual_memories = first_element
else:
actual_memories = memories_result
assert isinstance(actual_memories, list)
assert len(actual_memories) == 1
assert actual_memories[0].id == "id1"
assert actual_memories[0].payload["data"] == "hello"
def test_list_compatible_with_main_py_delete_all(self, db):
"""Simulate how main.py delete_all uses list()[0]."""
mock_response = MagicMock()
mock_response.rows = [
_make_row("id1", dist=0.1, data="hello"),
_make_row("id2", dist=0.2, data="world"),
]
db.namespace.query.return_value = mock_response
result = db.list(filters={"user_id": "u1"})
memories = result[0]
assert isinstance(memories, list)
for mem in memories:
assert hasattr(mem, "id")
assert hasattr(mem, "payload")
# ── count ────────────────────────────────────────────────────────────
class TestCount:
def test_count(self, db):
mock_metadata = MagicMock()
mock_metadata.approx_row_count = 100
db.namespace.metadata.return_value = mock_metadata
assert db.count() == 100
def test_count_handles_error(self, db):
db.namespace.metadata.side_effect = Exception("error")
assert db.count() == 0
# ── reset ────────────────────────────────────────────────────────────
class TestReset:
def test_reset_calls_delete_all(self, db):
db.reset()
db.namespace.delete_all.assert_called_once()
# ── Config ───────────────────────────────────────────────────────────
class TestConfig:
def test_config_valid(self):
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "key"}):
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
config = TurbopufferConfig()
assert config.collection_name == "mem0"
assert config.embedding_model_dims == 1536
assert config.distance_metric == "cosine_distance"
assert config.batch_size == 100
assert config.region == "gcp-us-central1"
def test_config_custom_values(self):
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
config = TurbopufferConfig(
collection_name="custom",
embedding_model_dims=768,
api_key="tpuf_key",
region="aws-us-west-2",
distance_metric="euclidean_squared",
batch_size=50,
)
assert config.collection_name == "custom"
assert config.embedding_model_dims == 768
assert config.api_key == "tpuf_key"
assert config.region == "aws-us-west-2"
assert config.distance_metric == "euclidean_squared"
assert config.batch_size == 50
def test_config_rejects_extra_fields(self):
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
with pytest.raises(ValueError, match="Extra fields not allowed"):
TurbopufferConfig(api_key="key", unknown_field="value")
def test_config_requires_api_key(self):
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
with patch.dict("os.environ", {}, clear=True):
import os
os.environ.pop("TURBOPUFFER_API_KEY", None)
with pytest.raises(ValueError, match="api_key"):
TurbopufferConfig()
def test_config_accepts_env_var(self):
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "env_key"}):
config = TurbopufferConfig()
assert config.api_key is None # not set explicitly, but env var is present
# ── Factory Registration ─────────────────────────────────────────────
class TestFactoryRegistration:
def test_vector_store_factory_has_turbopuffer(self):
from mem0.utils.factory import VectorStoreFactory
assert "turbopuffer" in VectorStoreFactory.provider_to_class
assert VectorStoreFactory.provider_to_class["turbopuffer"] == "mem0.vector_stores.turbopuffer.TurbopufferDB"
def test_vector_store_config_has_turbopuffer(self):
from mem0.vector_stores.configs import VectorStoreConfig
config = VectorStoreConfig.__private_attributes__["_provider_configs"].default
assert "turbopuffer" in config
assert config["turbopuffer"] == "TurbopufferConfig"
def test_config_validation_pipeline(self):
"""Test that VectorStoreConfig correctly resolves turbopuffer config."""
from mem0.vector_stores.configs import VectorStoreConfig
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "key"}):
config = VectorStoreConfig(
provider="turbopuffer",
config={"collection_name": "test", "region": "gcp-us-central1"},
)
assert config.config.collection_name == "test"
assert config.config.region == "gcp-us-central1"
assert config.config.embedding_model_dims == 1536
# ── OutputData ───────────────────────────────────────────────────────
class TestOutputData:
def test_output_data_has_required_fields(self):
od = OutputData(id="test", score=0.9, payload={"data": "hello"})
assert od.id == "test"
assert od.score == 0.9
assert od.payload == {"data": "hello"}
def test_output_data_nullable_fields(self):
od = OutputData(id=None, score=None, payload=None)
assert od.id is None
assert od.score is None
assert od.payload is None
def test_output_data_payload_access_pattern(self):
"""Test the exact access pattern used by main.py."""
od = OutputData(
id="mem1",
score=0.95,
payload={
"data": "User likes sci-fi movies",
"hash": "abc123",
"created_at": "2025-01-01",
"updated_at": "2025-06-01",
"user_id": "alice",
"agent_id": "agent1",
},
)
assert od.payload.get("data", "") == "User likes sci-fi movies"
assert od.payload.get("hash") == "abc123"
assert od.payload.get("created_at") == "2025-01-01"
assert od.payload.get("user_id") == "alice"
assert od.payload.get("run_id") is None # not present, should return None
+4 -4
View File
@@ -60,7 +60,7 @@ def test_search_vectors(upstash_instance, mock_index):
results = upstash_instance.search(
query="hello world",
vectors=vectors,
limit=2,
top_k=2,
filters={"age": 30, "name": "John"},
)
@@ -132,7 +132,7 @@ def test_list_vectors(upstash_instance):
filters = {"age": 30, "name": "John"}
print("filters", filters)
[results] = upstash_instance.list(filters=filters, limit=15)
[results] = upstash_instance.list(filters=filters, top_k=15)
upstash_instance.client.info.return_value = {
"dimension": 10,
@@ -193,7 +193,7 @@ def test_search_vectors_with_embeddings(upstash_instance_with_embeddings, mock_i
results = upstash_instance_with_embeddings.search(
query="hello world",
vectors=[],
limit=2,
top_k=2,
filters={"age": 30, "name": "John"},
)
@@ -302,7 +302,7 @@ def test_search_vectors_empty_filters(upstash_instance):
results = upstash_instance.search(
query="hello world",
vectors=vectors,
limit=1,
top_k=1,
filters=None,
)
+53 -7
View File
@@ -59,7 +59,7 @@ def test_search_filter_syntax(valkey_db, mock_valkey_client):
valkey_db.search(
query="test query",
vectors=np.random.rand(1536).tolist(),
limit=5,
top_k=5,
filters={"user_id": "test_user"},
)
@@ -72,7 +72,7 @@ def test_search_filter_syntax(valkey_db, mock_valkey_client):
valkey_db.search(
query="test query",
vectors=np.random.rand(1536).tolist(),
limit=5,
top_k=5,
filters={"user_id": "test_user", "agent_id": "test_agent"},
)
@@ -104,7 +104,7 @@ def test_search_without_filters(valkey_db, mock_valkey_client):
results = valkey_db.search(
query="test query",
vectors=np.random.rand(1536).tolist(),
limit=5,
top_k=5,
)
# Check that the search was called with the correct syntax
@@ -204,6 +204,52 @@ def test_update_handles_missing_created_at(valkey_db, mock_valkey_client):
assert "created_at" in kwargs["mapping"] # Should be added automatically
def test_update_with_none_vector_preserves_embedding(valkey_db, mock_valkey_client):
"""Test that update with vector=None does not corrupt the stored embedding.
Regression test for #4336: when vector=None is passed (metadata-only update),
np.array(None) silently creates a 4-byte scalar, overwriting the real embedding.
The fix skips the embedding field entirely so the existing value is preserved.
"""
payload = {
"hash": "test_hash",
"data": "updated_data",
"created_at": datetime.now(pytz.timezone("UTC")).isoformat(),
"user_id": "test_user",
}
valkey_db.update(vector_id="test_id", vector=None, payload=payload)
mock_valkey_client.hset.assert_called_once()
args, kwargs = mock_valkey_client.hset.call_args
assert "embedding" not in kwargs["mapping"], (
"embedding should not be in hash_data when vector is None"
)
assert kwargs["mapping"]["memory_id"] == "test_id"
assert kwargs["mapping"]["memory"] == "updated_data"
def test_update_with_vector_includes_embedding(valkey_db, mock_valkey_client):
"""Test that update with a real vector includes the embedding in hash_data."""
vector = np.random.rand(1536).tolist()
payload = {
"hash": "test_hash",
"data": "updated_data",
"created_at": datetime.now(pytz.timezone("UTC")).isoformat(),
"user_id": "test_user",
}
valkey_db.update(vector_id="test_id", vector=vector, payload=payload)
mock_valkey_client.hset.assert_called_once()
args, kwargs = mock_valkey_client.hset.call_args
assert "embedding" in kwargs["mapping"], (
"embedding should be in hash_data when vector is provided"
)
expected_bytes = np.array(vector, dtype=np.float32).tobytes()
assert kwargs["mapping"]["embedding"] == expected_bytes
def test_get(valkey_db, mock_valkey_client):
"""Test getting a vector."""
# Mock hgetall to return a vector
@@ -359,7 +405,7 @@ def test_list(valkey_db, mock_valkey_client):
mock_ft.search.return_value = mock_results
# Call list
results = valkey_db.list(filters={"user_id": "test_user"}, limit=10)
results = valkey_db.list(filters={"user_id": "test_user"}, top_k=10)
# Check that search was called with the correct arguments
mock_ft.search.assert_called_once()
@@ -391,7 +437,7 @@ def test_search_error_handling(valkey_db, mock_valkey_client):
valkey_db.search(
query="test query",
vectors=np.random.rand(1536).tolist(),
limit=5,
top_k=5,
filters={"user_id": "test_user"},
)
@@ -730,7 +776,7 @@ def test_search_with_invalid_metadata(valkey_db, mock_valkey_client):
mock_valkey_client.ft.return_value.search.return_value = mock_result
# Should handle invalid JSON gracefully
results = valkey_db.search(query="test query", vectors=np.random.rand(1536).tolist(), limit=5)
results = valkey_db.search(query="test query", vectors=np.random.rand(1536).tolist(), top_k=5)
assert len(results) == 1
@@ -744,7 +790,7 @@ def test_search_with_hnsw_ef_runtime(valkey_db, mock_valkey_client):
mock_result.docs = []
mock_valkey_client.ft.return_value.search.return_value = mock_result
valkey_db.search(query="test query", vectors=np.random.rand(1536).tolist(), limit=5)
valkey_db.search(query="test query", vectors=np.random.rand(1536).tolist(), top_k=5)
# Verify the search was called
assert mock_valkey_client.ft.return_value.search.called
@@ -100,7 +100,7 @@ def test_search_vectors(vector_store, mock_vertex_ai):
mock_vertex_ai["endpoint"].find_neighbors.return_value = [[mock_neighbor]]
results = vector_store.search(query="", vectors=vectors, filters=filters, limit=1)
results = vector_store.search(query="", vectors=vectors, filters=filters, top_k=1)
mock_vertex_ai["endpoint"].find_neighbors.assert_called_once_with(
deployed_index_id=vector_store.deployment_index_id,
+4 -4
View File
@@ -1,10 +1,10 @@
import os
import uuid
import httpx
import unittest
import uuid
from unittest.mock import MagicMock, patch
import dotenv
import httpx
import weaviate
from weaviate.exceptions import UnexpectedStatusCodeException
@@ -137,7 +137,7 @@ class TestWeaviateDB(unittest.TestCase):
mock_hybrid.return_value = mock_response
vectors = [[0.1] * 1536]
results = self.weaviate_db.search(query="", vectors=vectors, limit=5)
results = self.weaviate_db.search(query="", vectors=vectors, top_k=5)
mock_hybrid.assert_called_once()
@@ -170,7 +170,7 @@ class TestWeaviateDB(unittest.TestCase):
self.client_mock.collections.get.return_value.query.fetch_objects = mock_fetch
mock_fetch.return_value = mock_response
results = self.weaviate_db.list(limit=10)
results = self.weaviate_db.list(top_k=10)
mock_fetch.assert_called_once()