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:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"]}}]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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("") == ""
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user