Merge branch 'main' into fix/vertexai-py-batch-size

This commit is contained in:
kartik-mem0
2026-08-14 17:22:49 +05:30
667 changed files with 45239 additions and 27055 deletions
+24
View File
@@ -0,0 +1,24 @@
# Python SDK tests (`tests/`)
pytest suite for the `mem0/` package.
## Commands
```bash
make install_all # optional deps; several tests need them
make test # pytest tests/
make test-py-3.9 # pin a Python version (3.9 through 3.12)
pytest tests/llms/test_openai.py::test_generate_response # single test
```
## Conventions
- Files are named `test_<module>.py`.
- Provider tests mirror the source tree: `tests/<category>/<provider_name>/`.
- pytest-mock for mocks, pytest-asyncio for the async surface.
- Ruff line length **120**, matching `mem0/`. See [`../mem0/AGENTS.md`](../mem0/AGENTS.md).
- Mock the provider SDK, never the code under test. A test that asserts the implementation back at itself is worse than no test.
- Bug fixes need a regression test that fails without the fix. Write it first and watch it fail.
Tests for other packages live with those packages: `mem0-ts/` (jest), `cli/python/tests/` (pytest), `cli/node/` (vitest), and each directory under `integrations/`.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
@@ -94,7 +94,7 @@ def test_embed_with_huggingface_base_url():
embedder = HuggingFaceEmbedding(config)
result = embedder.embed("Hello from custom endpoint")
mock_openai.assert_called_once_with(base_url="http://localhost:8080")
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key=None)
mock_client.embeddings.create.assert_called_once_with(
input="Hello from custom endpoint",
model="my-custom-model",
@@ -103,6 +103,18 @@ def test_embed_with_huggingface_base_url():
assert result == [0.1, 0.2, 0.3]
def test_embed_with_huggingface_base_url_forwards_api_key():
config = BaseEmbedderConfig(
huggingface_base_url="http://localhost:8080",
model="my-custom-model",
api_key="tei-token",
)
with patch("mem0.embeddings.huggingface.OpenAI") as mock_openai:
HuggingFaceEmbedding(config)
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="tei-token")
def test_embed_batch_sentence_transformer(mock_sentence_transformer):
config = BaseEmbedderConfig()
embedder = HuggingFaceEmbedding(config)
+112 -8
View File
@@ -19,14 +19,14 @@ def mock_anthropic_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")
config = AnthropicConfig(model="claude-opus-4-6", 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")
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
llm = AnthropicLLM(config)
mock_response = Mock()
@@ -47,7 +47,7 @@ def test_generate_response_does_not_send_top_p_by_default(mock_anthropic_client)
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)
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key", top_p=0.9, temperature=None)
llm = AnthropicLLM(config)
mock_response = Mock()
@@ -68,7 +68,7 @@ def test_generate_response_sends_top_p_alone_when_no_temperature(mock_anthropic_
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)
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key", top_p=0.9, temperature=0.5)
llm = AnthropicLLM(config)
mock_response = Mock()
@@ -87,7 +87,7 @@ def test_configured_base_url_is_passed_to_client():
"""A configured anthropic_base_url must reach the Anthropic client constructor."""
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
config = AnthropicConfig(
model="claude-3-5-sonnet-20240620",
model="claude-opus-4-6",
api_key="test-key",
anthropic_base_url="https://proxy.example.com",
)
@@ -100,7 +100,7 @@ def test_base_url_falls_back_to_env(monkeypatch):
"""When no base_url is configured, ANTHROPIC_BASE_URL is used."""
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env.example.com")
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
AnthropicLLM(config)
assert mock_anthropic.Anthropic.call_args[1]["base_url"] == "https://env.example.com"
@@ -110,7 +110,7 @@ def test_base_url_omitted_when_unset(monkeypatch):
"""With no base_url anywhere, base_url must not be forced to None on the client."""
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
AnthropicLLM(config)
assert "base_url" not in mock_anthropic.Anthropic.call_args[1]
@@ -118,7 +118,7 @@ def test_base_url_omitted_when_unset(monkeypatch):
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")
base_config = BaseLlmConfig(model="claude-opus-4-6", api_key="test-key")
llm = AnthropicLLM(base_config)
mock_response = Mock()
@@ -131,3 +131,107 @@ def test_base_config_conversion_does_not_send_both(mock_anthropic_client):
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
assert "temperature" in call_kwargs
assert "top_p" not in call_kwargs
@pytest.mark.parametrize(
("model", "expected"),
[
("opus", False),
("claude-opus-4-6", True),
("claude-opus-4-7", False),
("claude-opus-4-8", False),
("sonnet", False),
("claude-sonnet-4-6", True),
("claude-sonnet-5", False),
("haiku", True),
("claude-haiku-4-5", True),
("claude-haiku-4-5-20251001", True),
("claude-fable-5", False),
("claude-mythos-5", False),
("claude-mythos-preview", False),
],
)
def test_enable_sampling_parameters_for_current_models(mock_anthropic_client, model, expected):
config = AnthropicConfig(model=model, api_key="test-key", top_p=0.9)
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 ("temperature" in call_kwargs) is expected
def test_generate_response_returns_text_when_thinking_block_precedes_it(mock_anthropic_client):
"""Extended thinking responses put a thinking block before the text block; parsing must skip it."""
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
llm = AnthropicLLM(config)
thinking_block = Mock(spec=["type", "thinking", "signature"])
thinking_block.type = "thinking"
text_block = Mock(spec=["type", "text"])
text_block.type = "text"
text_block.text = "The answer"
mock_response = Mock()
mock_response.content = [thinking_block, text_block]
mock_anthropic_client.messages.create.return_value = mock_response
result = llm.generate_response([{"role": "user", "content": "Hi"}])
assert result == "The answer"
def test_generate_response_returns_empty_string_on_empty_content(mock_anthropic_client):
"""A response with no content blocks must return "" instead of raising IndexError."""
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
llm = AnthropicLLM(config)
mock_response = Mock()
mock_response.content = []
mock_anthropic_client.messages.create.return_value = mock_response
result = llm.generate_response([{"role": "user", "content": "Hi"}])
assert result == ""
def test_generate_response_returns_text_for_plain_text_response(mock_anthropic_client):
"""Regular single-text-block responses keep returning the text unchanged."""
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
llm = AnthropicLLM(config)
text_block = Mock(spec=["type", "text"])
text_block.type = "text"
text_block.text = "Hello!"
mock_response = Mock()
mock_response.content = [text_block]
mock_anthropic_client.messages.create.return_value = mock_response
assert llm.generate_response([{"role": "user", "content": "Hi"}]) == "Hello!"
def test_enable_sampling_parameters_respects_explicit_override(mock_anthropic_client):
config = AnthropicConfig(
model="claude-opus-4-6",
api_key="test-key",
top_p=0.9,
enable_sampling_parameters=False,
)
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 "temperature" not in call_kwargs
assert "top_p" not in call_kwargs
+55 -1
View File
@@ -6,7 +6,6 @@ from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
from mem0.llms.aws_bedrock import AWSBedrockLLM, extract_provider
from mem0.utils.factory import LlmFactory
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -70,6 +69,23 @@ class TestExtractProvider:
with pytest.raises(ValueError, match="Unknown provider"):
extract_provider("unknown-vendor.some-model-v1:0")
def test_application_inference_profile_arn_without_override_raises(self):
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
with pytest.raises(ValueError, match="Unknown provider"):
extract_provider(arn)
def test_application_inference_profile_arn_with_explicit_provider(self):
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
assert extract_provider(arn, "anthropic") == "anthropic"
def test_explicit_provider_takes_precedence_over_regex(self):
assert extract_provider("anthropic.claude-3-5-sonnet-20240620-v1:0", "amazon") == "amazon"
def test_explicit_provider_typo_raises(self):
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
with pytest.raises(ValueError, match="Unknown provider_override 'anthorpic'"):
extract_provider(arn, "anthorpic")
# ---------------------------------------------------------------------------
# AWSBedrockConfig
@@ -114,6 +130,44 @@ class TestAWSBedrockConfig:
)
assert config.aws_region == "us-east-2"
def test_provider_override_defaults_to_none(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
assert config.provider_override is None
def test_provider_override_stored(self):
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
config = AWSBedrockConfig(model=arn, provider_override="anthropic")
assert config.provider_override == "anthropic"
# ---------------------------------------------------------------------------
# AWSBedrockLLM with application inference profile ARNs
# ---------------------------------------------------------------------------
class TestApplicationInferenceProfileArn:
ARN = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
def test_arn_without_provider_override_raises(self, mock_boto3):
with pytest.raises(ValueError, match="Unknown provider"):
_make_llm(self.ARN, mock_boto3)
def test_arn_with_provider_override_resolves(self, mock_boto3):
llm = _make_llm(self.ARN, mock_boto3, provider_override="anthropic")
assert llm.provider == "anthropic"
assert llm.supports_tools is True
def test_plain_model_id_unaffected(self, mock_boto3):
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
assert llm.provider == "anthropic"
def test_cross_region_inference_profile_unaffected(self, mock_boto3):
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3)
assert llm.provider == "anthropic"
def test_arn_with_misspelled_provider_override_raises(self, mock_boto3):
with pytest.raises(ValueError, match="Unknown provider_override 'anthorpic'"):
_make_llm(self.ARN, mock_boto3, provider_override="anthorpic")
# ---------------------------------------------------------------------------
# LlmFactory
+11
View File
@@ -49,3 +49,14 @@ def test_regular_model_sends_sampling_params(mock_openai_client):
assert "max_tokens" in call_kwargs # standard sampling params still forwarded
assert "top_p" in call_kwargs
assert call_kwargs["model"] == "gpt-4o"
def test_uses_openai_base_url_environment_variable(monkeypatch):
base_url = "https://gateway.example/v1"
monkeypatch.setenv("OPENAI_API_BASE", "https://legacy.example/v1")
monkeypatch.setenv("OPENAI_BASE_URL", base_url)
with patch("mem0.llms.openai_structured.OpenAI") as mock_openai:
OpenAIStructuredLLM(OpenAIConfig(api_key="test-api-key"))
mock_openai.assert_called_once_with(api_key="test-api-key", base_url=base_url)
+54
View File
@@ -4,6 +4,7 @@ import pytest
from mem0 import AsyncMemory, Memory
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.vllm import VllmConfig
from mem0.llms.vllm import VllmLLM
@@ -15,6 +16,59 @@ def mock_vllm_client():
yield mock_client
def test_base_url_explicit_arg_wins_over_env(mock_vllm_client, monkeypatch):
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
config = VllmConfig(vllm_base_url="http://from-arg:8000/v1")
VllmLLM(config)
assert config.vllm_base_url == "http://from-arg:8000/v1"
def test_base_url_env_wins_over_default(mock_vllm_client, monkeypatch):
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
config = VllmConfig()
VllmLLM(config)
assert config.vllm_base_url == "http://from-env:8000/v1"
def test_base_url_default_when_neither_set(mock_vllm_client, monkeypatch):
monkeypatch.delenv("VLLM_BASE_URL", raising=False)
config = VllmConfig()
VllmLLM(config)
assert config.vllm_base_url == "http://localhost:8000/v1"
def test_openai_client_receives_explicit_base_url(monkeypatch):
monkeypatch.delenv("VLLM_BASE_URL", raising=False)
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
VllmLLM(VllmConfig(vllm_base_url="http://from-arg:8000/v1"))
assert mock_openai.call_args.kwargs["base_url"] == "http://from-arg:8000/v1"
def test_openai_client_receives_base_url_from_env(monkeypatch):
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
VllmLLM(VllmConfig())
base_url = mock_openai.call_args.kwargs["base_url"]
assert base_url == "http://from-env:8000/v1"
assert base_url != "http://localhost:8000/v1"
def test_openai_client_receives_base_url_from_env_via_base_llm_config(monkeypatch):
"""Original #6256 repro: BaseLlmConfig (not VllmConfig) plus VLLM_BASE_URL only."""
monkeypatch.setenv("VLLM_BASE_URL", "http://remote-vllm:8000/v1")
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
VllmLLM(BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct"))
base_url = mock_openai.call_args.kwargs["base_url"]
assert base_url == "http://remote-vllm:8000/v1"
assert base_url != "http://localhost:8000/v1"
def test_generate_response_without_tools(mock_vllm_client):
config = BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct", temperature=0.7, max_tokens=100, top_p=1.0)
llm = VllmLLM(config)
+37 -2
View File
@@ -91,7 +91,7 @@ def test_sync_delete_failure_does_not_trigger_decay_usage_notice(monkeypatch):
def test_sync_delete_all_decay_usage_runs_after_success(monkeypatch):
memory = make_sync_memory()
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
memory.vector_store.list.return_value = (memories, None)
memory.vector_store.list.side_effect = [(memories, None), ([], None)]
decay_notice = MagicMock()
first_run_notice = MagicMock()
detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2))
@@ -117,6 +117,21 @@ def test_sync_delete_all_decay_usage_runs_after_success(monkeypatch):
first_run_notice.assert_not_called()
def test_sync_delete_all_stops_when_vector_store_repeats_batch(monkeypatch):
memory = make_sync_memory()
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
memory.vector_store.list.side_effect = [(memories, None), (memories, None)]
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", MagicMock(return_value=None))
monkeypatch.setattr(memory_main, "display_first_run_notice", MagicMock())
result = Memory.delete_all(memory, user_id="u1")
assert result == {"message": "Memories deleted successfully!"}
assert memory.vector_store.list.call_count == 2
assert memory._delete_memory.call_count == 2
def test_sync_delete_all_zero_deletes_uses_first_run_notice(monkeypatch):
memory = make_sync_memory()
memory.vector_store.list.return_value = ([], None)
@@ -189,7 +204,7 @@ async def test_async_delete_failure_does_not_trigger_decay_usage_notice(monkeypa
async def test_async_delete_all_decay_usage_runs_after_success(monkeypatch):
memory = make_async_memory()
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
memory.vector_store.list.return_value = (memories, None)
memory.vector_store.list.side_effect = [(memories, None), ([], None)]
decay_notice = AsyncMock()
first_run_notice = AsyncMock()
detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2))
@@ -213,3 +228,23 @@ async def test_async_delete_all_decay_usage_runs_after_success(monkeypatch):
2,
)
first_run_notice.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_delete_all_stops_when_vector_store_repeats_batch(monkeypatch):
memory = make_async_memory()
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
memory.vector_store.list.side_effect = [
(memories, None),
(memories, None),
RuntimeError("delete_all should have stopped before a third list() call"),
]
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", MagicMock(return_value=None))
monkeypatch.setattr(memory_main, "display_first_run_notice_async", AsyncMock())
result = await AsyncMemory.delete_all(memory, user_id="u1")
assert result == {"message": "Memories deleted successfully!"}
assert memory.vector_store.list.call_count == 2
assert memory._delete_memory.await_count == 2
+141 -46
View File
@@ -418,6 +418,147 @@ async def test_async_update_memory_uses_utc_timestamps(mocker):
assert payload["updated_at"] is not None
_ATTACKER_UPDATE_METADATA = {
"user_id": "attacker_tenant",
"agent_id": "attacker_agent",
"run_id": "attacker_run",
"actor_id": "attacker_actor",
"category": "sports",
}
# Omits agent_id on purpose, so one payload covers both overwriting and injecting an identity field.
_EXISTING_UPDATE_PAYLOAD = {
"data": "old memory",
"user_id": "tenant_a",
"run_id": "run_a",
"actor_id": "actor_a",
}
def test_update_memory_metadata_cannot_change_identity_fields(mocker, caplog):
"""Regression (issues #4490, #6277): update() metadata must not overwrite or inject identity fields."""
memory = _build_memory_instance(mocker, Memory)
memory.vector_store.get.return_value = MagicMock(payload=dict(_EXISTING_UPDATE_PAYLOAD))
with caplog.at_level(logging.WARNING, logger="mem0.memory.main"):
memory._update_memory("memory-id", "new memory", {}, metadata=dict(_ATTACKER_UPDATE_METADATA))
payload = memory.vector_store.update.call_args.kwargs["payload"]
assert payload["user_id"] == "tenant_a"
assert payload["run_id"] == "run_a"
assert "agent_id" not in payload
assert payload["actor_id"] == "actor_a"
assert payload["category"] == "sports"
assert payload["data"] == "new memory"
assert "ignoring metadata['user_id']" in caplog.text
_ATTACKER_ADD_METADATA = {
"agent_id": "victim-agent",
"run_id": "victim-run",
"actor_id": "victim-actor",
"category": "sports",
}
def _captured_add_metadata(memory, mocker, **add_kwargs):
"""Run add() with the pipeline stubbed and return the metadata template it produced."""
captured = {}
def _capture(messages, metadata, filters, infer, **kwargs):
captured.update(metadata)
return []
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
memory.add("I like coffee", infer=False, **add_kwargs)
return captured
def test_add_metadata_cannot_set_identity_fields(mocker, caplog):
"""Regression (issue #6655): add() metadata must not inject identity scope.
The caller scopes by user_id only, so the agent_id/run_id re-pins in
_build_filters_and_metadata never fire and cannot defend the payload.
"""
memory = _build_memory_instance(mocker, Memory)
with caplog.at_level(logging.WARNING, logger="mem0.memory.main"):
metadata = _captured_add_metadata(
memory, mocker, user_id="attacker", metadata=dict(_ATTACKER_ADD_METADATA)
)
assert metadata["user_id"] == "attacker"
for key in ("agent_id", "run_id", "actor_id"):
assert key not in metadata, f"{key} was injected through add() metadata"
# Non-identity metadata is untouched.
assert metadata["category"] == "sports"
assert "ignoring metadata['agent_id']" in caplog.text
@pytest.mark.asyncio
async def test_async_add_metadata_cannot_set_identity_fields(mocker):
"""Async counterpart of test_add_metadata_cannot_set_identity_fields."""
memory = _build_memory_instance(mocker, AsyncMemory)
captured = {}
async def _capture(messages, metadata, filters, infer, **kwargs):
captured.update(metadata)
return []
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
await memory.add(
"I like coffee",
user_id="attacker",
metadata=dict(_ATTACKER_ADD_METADATA),
infer=False,
)
assert captured["user_id"] == "attacker"
for key in ("agent_id", "run_id", "actor_id"):
assert key not in captured, f"{key} was injected through async add() metadata"
assert captured["category"] == "sports"
def test_add_entity_params_still_set_scope(mocker):
"""The documented top-level params remain the only way to set scope."""
memory = _build_memory_instance(mocker, Memory)
metadata = _captured_add_metadata(
memory, mocker, user_id="u1", agent_id="a1", run_id="r1", metadata={"category": "sports"}
)
assert metadata["user_id"] == "u1"
assert metadata["agent_id"] == "a1"
assert metadata["run_id"] == "r1"
assert metadata["category"] == "sports"
def test_add_without_metadata_is_unaffected(mocker):
"""No metadata argument means no stripping and no behaviour change."""
memory = _build_memory_instance(mocker, Memory)
metadata = _captured_add_metadata(memory, mocker, user_id="u1")
assert metadata == {"user_id": "u1"}
@pytest.mark.asyncio
async def test_async_update_memory_metadata_cannot_change_identity_fields(mocker):
"""Async counterpart of test_update_memory_metadata_cannot_change_identity_fields."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.vector_store.get.return_value = MagicMock(payload=dict(_EXISTING_UPDATE_PAYLOAD))
await memory._update_memory("memory-id", "new memory", {}, metadata=dict(_ATTACKER_UPDATE_METADATA))
payload = memory.vector_store.update.call_args.kwargs["payload"]
assert payload["user_id"] == "tenant_a"
assert payload["run_id"] == "run_a"
assert "agent_id" not in payload
assert payload["actor_id"] == "actor_a"
assert payload["category"] == "sports"
assert payload["data"] == "new memory"
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)
@@ -714,52 +855,6 @@ class TestMetadataNotMutated:
)
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"
def _make_match(score, linked_memory_ids):
return SimpleNamespace(score=score, payload={"linked_memory_ids": linked_memory_ids})
+25 -1
View File
@@ -1,9 +1,12 @@
import pytest
from unittest.mock import Mock
import pytest
from mem0.memory.utils import (
parse_messages,
parse_vision_messages,
process_telemetry_filters,
remove_code_blocks,
remove_spaces_from_entities,
sanitize_relationship_for_cypher,
)
@@ -114,6 +117,17 @@ class TestParseVisionMessages:
parse_vision_messages(messages, llm=mock_llm)
mock_llm.generate_response.assert_not_called()
def test_download_failure_preserves_original_exception(self):
mock_llm = Mock()
mock_llm.generate_response.side_effect = ValueError("network down")
messages = [
{"role": "user", "content": {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}}
]
with pytest.raises(Exception) as exc_info:
parse_vision_messages(messages, llm=mock_llm)
assert isinstance(exc_info.value.__cause__, ValueError)
assert "network down" in str(exc_info.value.__cause__)
class TestRemoveSpacesFromEntities:
"""
@@ -168,3 +182,13 @@ class TestRemoveSpacesFromEntities:
f = remove_spaces_from_entities([dict(base)], sanitize_relationship=False)[0]["relationship"]
assert t == sanitize_relationship_for_cypher("a/b")
assert f == "a/b"
class TestProcessTelemetryFilters:
def test_none_filters_returns_empty_list_and_dict(self):
assert process_telemetry_filters(None) == ([], {})
class TestRemoveCodeBlocks:
def test_none_content_returns_empty_string(self):
assert remove_code_blocks(None) == ""
+131
View File
@@ -0,0 +1,131 @@
import itertools
import pytest
from mem0.memory.main import Memory, _build_session_scope, _escape_scope_value
DELIMITER_VALUES = [
"u1",
"r1",
"a1",
"%",
"&",
"=",
"%25",
"%26",
"%3D",
"a==",
"a1&run_id=r1",
"a1&user_id=u1",
"r1&user_id=u1",
"a1&run_id=r1&user_id=u1",
]
class TestBuildSessionScope:
"""Tests that _build_session_scope produces a unique key per id combination."""
def test_ordinary_ids_produce_unchanged_scope_keys(self):
"""Ids without delimiter characters keep producing the pre-fix key format."""
cases = [
({"user_id": "550e8400-e29b-41d4-a716-446655440000"}, "user_id=550e8400-e29b-41d4-a716-446655440000"),
({"agent_id": "agent.assistant:v2"}, "agent_id=agent.assistant:v2"),
({"run_id": "12345"}, "run_id=12345"),
(
{"user_id": "user@example.com", "agent_id": "support-bot"},
"agent_id=support-bot&user_id=user@example.com",
),
(
{"user_id": "u1", "agent_id": "a1", "run_id": "r1"},
"agent_id=a1&run_id=r1&user_id=u1",
),
]
for filters, expected in cases:
assert _build_session_scope(filters) == expected
def test_ids_containing_delimiters_do_not_collide(self):
"""A value that embeds the join syntax no longer maps to the same key as the equivalent split filters."""
collapsed_run = {"run_id": "proj-x&user_id=u1"}
split_run = {"user_id": "u1", "run_id": "proj-x"}
assert _build_session_scope(collapsed_run) != _build_session_scope(split_run)
assert _build_session_scope(collapsed_run) == "run_id=proj-x%26user_id%3Du1"
collapsed_agent = {"run_id": "proj-y&agent_id=a1"}
split_agent = {"agent_id": "a1", "run_id": "proj-y"}
assert _build_session_scope(collapsed_agent) != _build_session_scope(split_agent)
def test_ids_containing_delimiters_get_a_new_key_format(self):
"""Ids holding a delimiter character map to a new key, so their buffer starts empty once after upgrade."""
assert _build_session_scope({"user_id": "dXNlcl9pZDE="}) == "user_id=dXNlcl9pZDE%3D"
assert _build_session_scope({"agent_id": "x&y"}) == "agent_id=x%26y"
assert _build_session_scope({"run_id": "50% off"}) == "run_id=50%25 off"
def test_scope_keys_are_unique_per_filter_combination(self):
"""Every distinct filter dict built from delimiter-heavy id values maps to a distinct scope key."""
keys = ["user_id", "agent_id", "run_id"]
seen = {}
for size in range(1, len(keys) + 1):
for key_subset in itertools.combinations(keys, size):
for combo in itertools.product(DELIMITER_VALUES, repeat=size):
filters = dict(zip(key_subset, combo))
scope = _build_session_scope(filters)
if scope in seen:
assert seen[scope] == filters, f"{seen[scope]} and {filters} both map to {scope!r}"
else:
seen[scope] = filters
class TestEscapeScopeValue:
"""Tests for the low-level per-value escaping helper."""
def test_non_string_input_is_stringified(self):
assert _escape_scope_value(42) == "42"
def test_percent_is_escaped_before_other_delimiters(self):
assert _escape_scope_value("%26") == "%2526"
assert _escape_scope_value("%26") != "%26"
def test_each_delimiter_is_escaped(self):
assert _escape_scope_value("%") == "%25"
assert _escape_scope_value("&") == "%26"
assert _escape_scope_value("=") == "%3D"
class TestSessionScopeWiring:
"""Tests that the add pipeline keys the conversation buffer through the builder."""
@pytest.fixture
def memory(self, mocker):
mocker.patch("mem0.memory.main.capture_event")
mock_embedder = mocker.MagicMock()
mock_embedder.return_value.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.utils.factory.EmbedderFactory.create", mock_embedder)
mock_vector_store = mocker.MagicMock()
mock_vector_store.return_value.search.return_value = []
mocker.patch(
"mem0.utils.factory.VectorStoreFactory.create",
side_effect=[mock_vector_store.return_value, mocker.MagicMock()],
)
mocker.patch("mem0.utils.factory.LlmFactory.create", mocker.MagicMock())
mocker.patch("mem0.memory.storage.SQLiteManager", mocker.MagicMock())
memory = Memory()
memory.config = mocker.MagicMock()
memory.config.custom_instructions = None
memory.custom_instructions = None
memory.api_version = "v1.1"
memory.db.get_last_messages = mocker.MagicMock(return_value=[])
memory.db.save_messages = mocker.MagicMock()
memory.llm.generate_response.return_value = '{"memory": []}'
return memory
def test_add_pipeline_uses_the_escaped_key(self, memory):
"""The pipeline must route through the builder, not assemble the key inline."""
memory._add_to_vector_store(
messages=[{"role": "user", "content": "hello"}],
metadata={},
filters={"run_id": "proj-x&user_id=u1"},
infer=True,
)
assert memory.db.get_last_messages.call_args[0][0] == "run_id=proj-x%26user_id%3Du1"
assert memory.db.save_messages.call_args[0][1] == "run_id=proj-x%26user_id%3Du1"
+32
View File
@@ -1,3 +1,5 @@
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
@@ -9,3 +11,33 @@ def mock_llm():
mock_llm_instance = MagicMock()
mock_factory.create.return_value = mock_llm_instance
yield mock_factory, mock_llm_instance
@pytest.fixture
def mock_cohere(monkeypatch):
"""Provide a fake ``cohere`` module so CohereReranker imports/constructs."""
fake_cohere = ModuleType("cohere")
fake_client = MagicMock()
fake_cohere.Client = MagicMock(return_value=fake_client)
monkeypatch.setitem(sys.modules, "cohere", fake_cohere)
import mem0.reranker.cohere_reranker as cohere_reranker
monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False)
monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False)
return cohere_reranker, fake_client
@pytest.fixture
def mock_zero_entropy(monkeypatch):
"""Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports."""
fake_module = ModuleType("zeroentropy")
fake_client = MagicMock()
fake_module.ZeroEntropy = MagicMock(return_value=fake_client)
monkeypatch.setitem(sys.modules, "zeroentropy", fake_module)
import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker
monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False)
monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False)
return zero_entropy_reranker, fake_client
+1 -1
View File
@@ -6,7 +6,7 @@ from mem0.reranker.llm_reranker import LLMReranker
class TestLLMRerankerConfig:
def test_default_config(self):
config = LLMRerankerConfig()
assert config.model == "gpt-4o-mini"
assert config.model == "gpt-5-mini"
assert config.provider == "openai"
assert config.temperature == 0.0
assert config.max_tokens == 100
@@ -0,0 +1,75 @@
"""Regression tests pinning that the reranker fallback path never mutates caller-owned documents."""
from unittest.mock import MagicMock, patch
from mem0.configs.rerankers.cohere import CohereRerankerConfig
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
from mem0.configs.rerankers.sentence_transformer import (
SentenceTransformerRerankerConfig,
)
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
from mem0.reranker.huggingface_reranker import HuggingFaceReranker
from mem0.reranker.sentence_transformer_reranker import SentenceTransformerReranker
def _docs(n):
return [{"memory": f"doc{i}"} for i in range(n)]
class TestCohereFallbackNoMutation:
def test_fallback_does_not_mutate_original_documents(self, mock_cohere):
module, fake_client = mock_cohere
fake_client.rerank.side_effect = RuntimeError("API error")
reranker = module.CohereReranker(CohereRerankerConfig(api_key="test-key"))
documents = _docs(3)
result = reranker.rerank("query", documents)
assert all("rerank_score" not in doc for doc in documents)
assert all(doc["rerank_score"] == 0.0 for doc in result)
class TestZeroEntropyFallbackNoMutation:
def test_fallback_does_not_mutate_original_documents(self, mock_zero_entropy):
module, fake_client = mock_zero_entropy
fake_client.models.rerank.side_effect = RuntimeError("API error")
reranker = module.ZeroEntropyReranker(ZeroEntropyRerankerConfig(api_key="test-key"))
documents = _docs(3)
result = reranker.rerank("query", documents)
assert all("rerank_score" not in doc for doc in documents)
assert all(doc["rerank_score"] == 0.0 for doc in result)
class TestHuggingFaceFallbackNoMutation:
def test_fallback_does_not_mutate_original_documents(self):
with (
patch("mem0.reranker.huggingface_reranker.AutoTokenizer") as mock_tokenizer_cls,
patch("mem0.reranker.huggingface_reranker.AutoModelForSequenceClassification") as mock_model_cls,
):
mock_tokenizer = MagicMock(side_effect=RuntimeError("tokenizer error"))
mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer
mock_model_cls.from_pretrained.return_value = MagicMock()
reranker = HuggingFaceReranker(HuggingFaceRerankerConfig())
documents = _docs(3)
result = reranker.rerank("query", documents)
assert all("rerank_score" not in doc for doc in documents)
assert all(doc["rerank_score"] == 0.0 for doc in result)
class TestSentenceTransformerFallbackNoMutation:
def test_fallback_does_not_mutate_original_documents(self):
with patch("mem0.reranker.sentence_transformer_reranker.CrossEncoder") as mock_cross_encoder_cls:
mock_model = MagicMock()
mock_model.predict.side_effect = RuntimeError("predict error")
mock_cross_encoder_cls.return_value = mock_model
reranker = SentenceTransformerReranker(SentenceTransformerRerankerConfig())
documents = _docs(3)
result = reranker.rerank("query", documents)
assert all("rerank_score" not in doc for doc in documents)
assert all(doc["rerank_score"] == 0.0 for doc in result)
+1 -44
View File
@@ -1,52 +1,9 @@
"""Regression tests for the reranker fallback path honoring ``config.top_k``.
When the underlying rerank call fails, the reranker falls back to returning the
documents in their original order. That fallback must still respect the
configured ``top_k`` limit, exactly like the success path does. The HuggingFace
and SentenceTransformer rerankers already behave this way; these tests pin the
same contract for the Cohere and ZeroEntropy rerankers.
"""
import sys
from types import ModuleType
from unittest.mock import MagicMock
import pytest
"""Regression tests pinning that the reranker fallback path still honors ``config.top_k``."""
from mem0.configs.rerankers.cohere import CohereRerankerConfig
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
@pytest.fixture
def mock_cohere(monkeypatch):
"""Provide a fake ``cohere`` module so CohereReranker imports/constructs."""
fake_cohere = ModuleType("cohere")
fake_client = MagicMock()
fake_cohere.Client = MagicMock(return_value=fake_client)
monkeypatch.setitem(sys.modules, "cohere", fake_cohere)
import mem0.reranker.cohere_reranker as cohere_reranker
monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False)
monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False)
return cohere_reranker, fake_client
@pytest.fixture
def mock_zero_entropy(monkeypatch):
"""Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports."""
fake_module = ModuleType("zeroentropy")
fake_client = MagicMock()
fake_module.ZeroEntropy = MagicMock(return_value=fake_client)
monkeypatch.setitem(sys.modules, "zeroentropy", fake_module)
import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker
monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False)
monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False)
return zero_entropy_reranker, fake_client
def _docs(n):
return [{"memory": f"doc{i}"} for i in range(n)]
+244
View File
@@ -8,6 +8,9 @@ import httpx
import pytest
import requests
from mem0.client.main import AsyncMemoryClient
from mem0.client.types import GetAllMemoryOptions, SearchMemoryOptions
@pytest.fixture
def mock_memory_client():
@@ -130,6 +133,199 @@ class TestGetAllEntityParamRejection:
)
class TestSearchTypedOptionsParity:
"""MEM-5893: SearchMemoryOptions' new typed fields serialize to their v3 snake_case keys."""
def test_search_options_pass_reference_date_latest_only_keyword_search(self, mock_memory_client):
"""search(options=SearchMemoryOptions(...)) should forward the new fields verbatim."""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_memory_client.client.post.return_value = mock_response
options = SearchMemoryOptions(
filters={"user_id": "u1"},
reference_date="2024-01-01",
latest_only=True,
keyword_search=True,
)
mock_memory_client.search("test query", options=options)
mock_memory_client.client.post.assert_called_once_with(
"/v3/memories/search/",
json={
"query": "test query",
"filters": {"user_id": "u1"},
"reference_date": "2024-01-01",
"latest_only": True,
"keyword_search": True,
},
)
class TestGetAllTypedOptionsParity:
"""MEM-5893: GetAllMemoryOptions.latest_only serializes to its v3 snake_case key."""
def test_get_all_options_pass_latest_only(self, mock_memory_client):
"""get_all(options=GetAllMemoryOptions(...)) should forward latest_only verbatim."""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_memory_client.client.post.return_value = mock_response
options = GetAllMemoryOptions(filters={"user_id": "u1"}, latest_only=True)
mock_memory_client.get_all(options=options)
mock_memory_client.client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}, "latest_only": True},
)
class TestGetAllPageSizeQueryParams:
"""MEM-6227: page and page_size must reach the API as query params independently."""
def test_get_all_page_size_alone_lands_in_query_params(self, mock_memory_client):
"""get_all(page_size=...) without page should send page_size as a query param."""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_memory_client.client.post.return_value = mock_response
mock_memory_client.get_all(filters={"user_id": "u1"}, page_size=50)
mock_memory_client.client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
params={"page_size": 50},
)
def test_get_all_page_alone_lands_in_query_params(self, mock_memory_client):
"""get_all(page=...) without page_size should send page as a query param."""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_memory_client.client.post.return_value = mock_response
mock_memory_client.get_all(filters={"user_id": "u1"}, page=2)
mock_memory_client.client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
params={"page": 2},
)
def test_get_all_page_and_page_size_together(self, mock_memory_client):
"""get_all(page=..., page_size=...) should send both as query params."""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_memory_client.client.post.return_value = mock_response
mock_memory_client.get_all(filters={"user_id": "u1"}, page=2, page_size=50)
mock_memory_client.client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
params={"page": 2, "page_size": 50},
)
def test_get_all_neither_page_nor_page_size_sends_no_query_params(self, mock_memory_client):
"""get_all() without page or page_size should not pass a params kwarg."""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_memory_client.client.post.return_value = mock_response
mock_memory_client.get_all(filters={"user_id": "u1"})
mock_memory_client.client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
)
def test_async_get_all_page_size_alone_lands_in_query_params(self):
asyncio.run(self._assert_async_get_all_page_size_alone())
async def _assert_async_get_all_page_size_alone(self):
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
client.async_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
client.async_client.post = AsyncMock(return_value=mock_response)
with patch("mem0.client.main.capture_client_event"):
await client.get_all(filters={"user_id": "u1"}, page_size=50)
client.async_client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
params={"page_size": 50},
)
def test_async_get_all_page_alone_lands_in_query_params(self):
asyncio.run(self._assert_async_get_all_page_alone())
async def _assert_async_get_all_page_alone(self):
"""get_all(page=0) without page_size should still send page=0 as a query param."""
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
client.async_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
client.async_client.post = AsyncMock(return_value=mock_response)
with patch("mem0.client.main.capture_client_event"):
await client.get_all(filters={"user_id": "u1"}, page=0)
client.async_client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
params={"page": 0},
)
def test_async_get_all_page_and_page_size_together(self):
asyncio.run(self._assert_async_get_all_page_and_page_size_together())
async def _assert_async_get_all_page_and_page_size_together(self):
"""get_all(page=0, page_size=0) should send both as query params despite being falsy."""
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
client.async_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
client.async_client.post = AsyncMock(return_value=mock_response)
with patch("mem0.client.main.capture_client_event"):
await client.get_all(filters={"user_id": "u1"}, page=0, page_size=0)
client.async_client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
params={"page": 0, "page_size": 0},
)
def test_async_get_all_neither_page_nor_page_size_sends_no_query_params(self):
asyncio.run(self._assert_async_get_all_neither_page_nor_page_size())
async def _assert_async_get_all_neither_page_nor_page_size(self):
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
client.async_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
client.async_client.post = AsyncMock(return_value=mock_response)
with patch("mem0.client.main.capture_client_event"):
await client.get_all(filters={"user_id": "u1"})
client.async_client.post.assert_called_once_with(
"/v3/memories/",
json={"filters": {"user_id": "u1"}},
)
class TestUpdateExpirationDate:
"""Tests for update expiration_date payload handling."""
@@ -389,3 +585,51 @@ class TestValidateApiKeyHttpError:
assert not isinstance(exc_info.value, requests.exceptions.JSONDecodeError)
assert "Error:" in str(exc_info.value)
class TestAddAgentCustomInstructions:
"""Per-request override of the project-level setting, forwarded to the add payload."""
def _mock_add(self, client):
response = MagicMock()
response.json.return_value = {"results": []}
response.raise_for_status.return_value = None
client.client.post.return_value = response
return response
def test_kwarg_reaches_the_add_payload(self, mock_memory_client):
self._mock_add(mock_memory_client)
mock_memory_client.add(
"hello",
filters={"agent_id": "a1"},
agent_custom_instructions="remember tool failures",
)
_, kwargs = mock_memory_client.client.post.call_args
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
def test_typed_option_reaches_the_add_payload(self, mock_memory_client):
from mem0.client.types import AddMemoryOptions
self._mock_add(mock_memory_client)
mock_memory_client.add(
"hello",
AddMemoryOptions(
filters={"agent_id": "a1"},
agent_custom_instructions="remember tool failures",
),
)
_, kwargs = mock_memory_client.client.post.call_args
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
def test_absent_when_not_passed(self, mock_memory_client):
"""Callers that don't use the feature send an unchanged payload."""
self._mock_add(mock_memory_client)
mock_memory_client.add("hello", filters={"user_id": "u1"})
_, kwargs = mock_memory_client.client.post.call_args
assert "agent_custom_instructions" not in kwargs["json"]
+46 -3
View File
@@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
import pytest
from mem0.configs.base import MemoryConfig
from mem0.memory.main import Memory
from mem0.memory.main import Memory, _validate_and_trim_entity_id
@pytest.fixture(autouse=True)
@@ -280,19 +280,36 @@ def test_delete(memory_instance):
def test_delete_all(memory_instance):
mock_memories = [Mock(id="1"), Mock(id="2")]
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
memory_instance.vector_store.list = Mock(side_effect=[(mock_memories, None), ([], None)])
memory_instance.vector_store.reset = Mock()
memory_instance._delete_memory = Mock()
result = memory_instance.delete_all(user_id="test_user")
assert memory_instance._delete_memory.call_count == 2
memory_instance.vector_store.list.assert_called_with(
filters={"user_id": "test_user"}, top_k=1000
)
# Ensure the collection is NOT dropped — only matched memories should be removed
memory_instance.vector_store.reset.assert_not_called()
assert result["message"] == "Memories deleted successfully!"
def test_delete_all_paginates_beyond_vector_store_page_size(memory_instance):
first_batch = [Mock(id=str(index)) for index in range(1000)]
second_batch = [Mock(id="1000")]
memory_instance.vector_store.list = Mock(
side_effect=[(first_batch, None), (second_batch, None), ([], None)]
)
memory_instance._delete_memory = Mock()
memory_instance.delete_all(user_id="test_user")
assert memory_instance._delete_memory.call_count == 1001
assert memory_instance.vector_store.list.call_count == 3
def test_get_all(memory_instance):
mock_memories = [Mock(id="1", payload={"data": "Memory 1", "user_id": "test_user"})]
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
@@ -431,7 +448,33 @@ class TestEntityIdValidation:
memory_instance.delete_all(user_id=" alice ")
memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "alice"})
memory_instance.vector_store.list.assert_called_once_with(
filters={"user_id": "alice"}, top_k=1000
)
def test_validate_coerces_non_string_entity_id(self):
"""Integer (and other non-string) ids are coerced to str, not crashed on."""
assert _validate_and_trim_entity_id(42, "user_id") == "42"
assert _validate_and_trim_entity_id(0, "user_id") == "0"
def test_delete_all_coerces_integer_user_id_before_list(self, memory_instance):
"""delete_all should accept an integer user_id and scope by its str form."""
memory_instance.vector_store.list = Mock(return_value=([], None))
memory_instance.delete_all(user_id=42)
memory_instance.vector_store.list.assert_called_once_with(
filters={"user_id": "42"}, top_k=1000
)
def test_get_all_coerces_integer_user_id(self, memory_instance):
"""get_all should accept an integer user_id in filters and scope by its str form."""
memory_instance.vector_store.list = Mock(return_value=([], None))
memory_instance.get_all(filters={"user_id": 42})
_, kwargs = memory_instance.vector_store.list.call_args
assert kwargs["filters"]["user_id"] == "42"
class TestSearchParamValidation:
+48 -3
View File
@@ -1,6 +1,7 @@
import json
import sys
from datetime import datetime
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -982,7 +983,7 @@ async def test_async_delete_all_continues_on_partial_failure(mock_sqlite, mock_l
mem3.id = "mem-3"
mem3.payload = {"data": "three", "created_at": "2024-01-01T00:00:00+00:00", "actor_id": None, "role": None}
mock_vector_store.list.return_value = ([mem1, mem2, mem3],)
mock_vector_store.list.side_effect = [([mem1, mem2, mem3],), ([],)]
def _get_side_effect(vector_id):
if vector_id == "mem-2":
@@ -998,6 +999,10 @@ async def test_async_delete_all_continues_on_partial_failure(mock_sqlite, mock_l
assert result == {"message": "Memories deleted successfully!"}
assert mock_vector_store.delete.call_count == 2
assert mock_vector_store.list.call_args_list[0].kwargs == {
"filters": {"user_id": "test-user"},
"top_k": 1000,
}
@patch('mem0.utils.factory.EmbedderFactory.create')
@@ -1472,7 +1477,7 @@ class TestAsyncDeleteAllEntityRace:
mem_b = MagicMock()
mem_b.id = "mem-b"
mem_b.payload = {"data": "Alice works at Acme", "user_id": "alice"}
mock_vector_store.list.return_value = ([mem_a, mem_b],)
mock_vector_store.list.side_effect = [([mem_a, mem_b],), ([],)]
mock_vector_store.get.side_effect = lambda vector_id: {"mem-a": mem_a, "mem-b": mem_b}[vector_id]
mock_vector_factory.return_value = mock_vector_store
@@ -1529,3 +1534,43 @@ async def test_async_procedural_memory_langchain_strips_code_blocks(mock_llm_fac
insert_call = memory.vector_store.insert.call_args
stored_data = insert_call[1]["payloads"][0]["data"]
assert "```" not in stored_data
@pytest.mark.asyncio
@patch("mem0.memory.main.VectorStoreFactory")
@patch("mem0.memory.main.EmbedderFactory")
@patch("mem0.memory.main.LlmFactory")
async def test_async_procedural_memory_default_path_without_langchain(mock_llm_factory, mock_emb, mock_vs):
"""Async procedural memory must not require langchain-core on the default
(llm=None) path, which uses self.llm and never calls convert_to_messages.
The sync path already works without it; this keeps the async path in parity.
"""
mock_vs.return_value = MagicMock()
mock_emb.return_value = MagicMock()
mock_emb.return_value.embed.return_value = [0.1] * 1536
mock_llm_factory.return_value = MagicMock()
from mem0.memory.main import AsyncMemory
config = MemoryConfig()
memory = AsyncMemory(config)
memory.vector_store = MagicMock()
memory.vector_store.insert = MagicMock()
memory.embedding_model.embed = Mock(return_value=[0.1] * 1536)
memory.llm.generate_response = Mock(return_value="- deploy with the release script")
messages = [{"role": "user", "content": "how do we deploy"}]
# Simulate langchain-core being unavailable; the default path must still work.
with patch.dict(
sys.modules,
{
"langchain_core": None,
"langchain_core.messages": None,
"langchain_core.messages.utils": None,
},
):
result = await memory._create_procedural_memory(messages, metadata={"agent_id": "agent_1"})
assert result["results"][0]["event"] == "ADD"
memory.llm.generate_response.assert_called_once()
+47 -3
View File
@@ -2,9 +2,9 @@
parameter-passthrough surface.
Verifies the kwarg → JSON payload mapping for every supported field
(``custom_instructions``, ``custom_categories``, ``retrieval_criteria``,
``multilingual``, ``decay``), the ValueError when no field is
provided, and the URL/method shape. The HTTP layer is mocked.
(``custom_instructions``, ``custom_categories``, ``multilingual``,
``decay``, ``agent_custom_instructions``), the ValueError when no field
is provided, and the URL/method shape. The HTTP layer is mocked.
"""
from unittest.mock import MagicMock, patch
@@ -84,6 +84,50 @@ class TestProjectUpdateDecay:
assert args[0] == "/api/v1/orgs/organizations/org1/projects/proj1/"
class TestProjectUpdateAgentCustomInstructions:
def test_agent_custom_instructions_sent_in_payload(self, project):
proj, http = project
proj.update(agent_custom_instructions="remember tool failures")
assert _patch_payload(http) == {"agent_custom_instructions": "remember tool failures"}
def test_agent_custom_instructions_alone_satisfies_the_guard(self, project):
"""It is a standalone field, so setting only it must not raise."""
proj, http = project
proj.update(agent_custom_instructions="remember tool failures")
assert http.patch.called
def test_empty_string_round_trips_to_clear_the_field(self, project):
"""An empty string must survive the ``is not None`` filter, not be dropped as falsy."""
proj, http = project
proj.update(agent_custom_instructions="")
assert _patch_payload(http) == {"agent_custom_instructions": ""}
def test_combined_with_custom_instructions(self, project):
"""Both sets in one PATCH: the split-instruction configuration."""
proj, http = project
proj.update(
custom_instructions="remember user preferences",
agent_custom_instructions="remember tool failures",
)
assert _patch_payload(http) == {
"custom_instructions": "remember user preferences",
"agent_custom_instructions": "remember tool failures",
}
def test_omitted_when_none(self, project):
"""Callers that don't pass it must send an unchanged payload."""
proj, http = project
proj.update(custom_instructions="be concise")
payload = _patch_payload(http)
assert payload == {"custom_instructions": "be concise"}
assert "agent_custom_instructions" not in payload
def test_no_args_raises_with_agent_instructions_in_message(self, project):
proj, _ = project
with pytest.raises(ValueError, match=r"agent_custom_instructions"):
proj.update()
class TestProjectUpdateBackwardsCompat:
def test_multilingual_only_still_works(self, project):
"""Pre-decay callers (multilingual only) keep working unchanged."""
+9
View File
@@ -51,3 +51,12 @@ def test_base_to_provider_without_reasoning_fields_still_builds():
assert isinstance(built, AnthropicConfig)
assert built.model == "claude-3-5-sonnet-20240620"
def test_dict_config_not_mutated_by_kwargs():
config = {"model": "gpt-4o-mini"}
with patch("mem0.utils.factory.load_class", return_value=Mock(return_value=Mock())):
LlmFactory.create("openai", config, api_key="secret")
assert config == {"model": "gpt-4o-mini"}
+21
View File
@@ -137,6 +137,27 @@ def test_search(mochow_instance, mock_mochow_client):
assert results[1].payload == {"name": "vector2"}
def test_search_converts_l2_distance_to_similarity(mochow_instance):
# On the L2 metric, Mochow returns raw distances (lower = closer). search()
# must convert them to similarity scores (higher = better) to satisfy the
# VectorStoreBase contract, mirroring the milvus provider.
mochow_instance.metric_type = "L2"
mock_search_results = Mock()
mock_search_results.rows = [
{"row": {"id": "id1", "metadata": {"name": "vector1"}}, "score": 0.5},
{"row": {"id": "id2", "metadata": {"name": "vector2"}}, "score": 2.0},
]
mochow_instance._table.vector_search.return_value = mock_search_results
results = mochow_instance.search(query="test", vectors=[0.1, 0.2, 0.3], top_k=2)
# 1.0 / (1.0 + distance): the closer memory must score higher than the far one.
assert results[0].score == pytest.approx(1.0 / 1.5)
assert results[1].score == pytest.approx(1.0 / 3.0)
assert results[0].score > results[1].score
def test_search_with_filters(mochow_instance, mock_mochow_client):
mochow_instance._table.vector_search.return_value = Mock(rows=[])
+50
View File
@@ -344,3 +344,53 @@ def test_chroma_config_rejects_no_config():
"""Test that ChromaDbConfig rejects when no connection config is provided."""
with pytest.raises(ValueError):
ChromaDbConfig()
def test_generate_where_clause_same_field_range_keeps_both_bounds():
"""A same-field range must produce both bounds, each as its own single-operator
clause combined with $and (ChromaDB rejects multi-operator field expressions).
Regression test: each operator previously overwrote the previous one, so
{"gte": 18, "lte": 65} silently degraded to {"$lte": 65} and returned rows
the caller explicitly excluded.
"""
result = ChromaDB._generate_where_clause({"age": {"gte": 18, "lte": 65}})
assert result == {"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]}
def test_generate_where_clause_or_with_same_field_range():
"""Same-field ranges inside $or branches must also keep both bounds."""
result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18, "lte": 65}}, {"vip": True}]})
assert result == {
"$or": [
{"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]},
{"vip": {"$eq": True}},
]
}
def test_generate_where_clause_or_with_multi_field_condition():
"""Multi-field conditions inside $or must be wrapped in $and — ChromaDB
rejects flat dicts with more than one field per level."""
result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18}, "vip": True}, {"city": "sh"}]})
assert result == {
"$or": [
{"$and": [{"age": {"$gte": 18}}, {"vip": {"$eq": True}}]},
{"city": {"$eq": "sh"}},
]
}
def test_generate_where_clause_not_contains_negates_instead_of_vanishing():
"""$not with contains/icontains must produce a negated clause.
Regression test: operators missing from the negation map were silently
dropped, which could erase the whole where clause and return unfiltered
results. contains falls back to equality on the positive path, so its
negation falls back to inequality.
"""
result = ChromaDB._generate_where_clause({"$not": [{"title": {"contains": "draft"}}]})
assert result == {"title": {"$ne": "draft"}}
result = ChromaDB._generate_where_clause({"$not": [{"title": {"icontains": "draft"}}]})
assert result == {"title": {"$ne": "draft"}}
+16 -2
View File
@@ -249,9 +249,23 @@ REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
def _redis_search_available(host, port, timeout=2):
"""RediSearch (the FT.* command family) is an optional module, not part of vanilla Redis."""
if not _tcp_reachable(host, port, timeout=timeout):
return False
try:
import redis
client = redis.Redis(host=host, port=port, socket_connect_timeout=timeout, socket_timeout=timeout)
modules = {m["name"].lower() for m in client.module_list()}
return "search" in modules
except Exception:
return False
@pytest.mark.skipif(
not _tcp_reachable(REDIS_HOST, REDIS_PORT),
reason=f"Redis not reachable at {REDIS_HOST}:{REDIS_PORT}",
not _redis_search_available(REDIS_HOST, REDIS_PORT),
reason=f"RediSearch module not available at {REDIS_HOST}:{REDIS_PORT} (requires Redis Stack, not vanilla Redis)",
)
class TestRedisThreshold:
def test_threshold_filtering(self):
+39
View File
@@ -209,6 +209,8 @@ class TestElasticsearchDB(unittest.TestCase):
self.assertEqual(body["knn"]["query_vector"], vectors)
self.assertEqual(body["knn"]["k"], 5)
self.assertEqual(body["knn"]["num_candidates"], 10)
# `size` must be set or ES caps the response at its default of 10 hits.
self.assertEqual(body["size"], 5)
# Verify results
self.assertEqual(len(results), 1)
@@ -216,6 +218,43 @@ class TestElasticsearchDB(unittest.TestCase):
self.assertEqual(results[0].score, 0.8)
self.assertEqual(results[0].payload, {"key1": "value1"})
def test_search_sets_size_to_top_k(self):
"""Regression for #5909: KNN search must set `size=top_k` or Elasticsearch caps
the response at its default of 10 hits regardless of `knn.k`.
Memory._search_vector_store over-fetches a scoring pool of `max(limit * 4, 60)`
candidates for hybrid re-ranking, so even a default search(top_k=20) reaches this
layer with top_k=80. Without `size`, that pool is silently truncated to 10.
"""
self.client_mock.search.return_value = {"hits": {"hits": []}}
# internal_limit in Memory._search_vector_store for a default search(top_k=20)
over_fetch = max(20 * 4, 60)
self.es_db.search(query="", vectors=[[0.1] * 1536], top_k=over_fetch)
body = self.client_mock.search.call_args[1]["body"]
self.assertEqual(body["size"], over_fetch)
self.assertEqual(body["knn"]["k"], over_fetch)
self.assertGreater(body["size"], 10)
def test_search_with_filters_keeps_size(self):
"""Filters must not displace the response size set for KNN search."""
self.client_mock.search.return_value = {"hits": {"hits": []}}
self.es_db.search(
query="",
vectors=[[0.1] * 1536],
top_k=25,
filters={"user_id": "u1"},
)
body = self.client_mock.search.call_args[1]["body"]
self.assertEqual(body["size"], 25)
self.assertEqual(
body["knn"]["filter"],
{"bool": {"must": [{"term": {"metadata.user_id": "u1"}}]}},
)
def test_custom_search_query(self):
# Mock custom search query
self.es_db.custom_search_query = Mock()
+44 -2
View File
@@ -107,6 +107,14 @@ class TestMilvusDB:
assert 'metadata["category"] == "work"' in filter_str
assert ' and ' in filter_str
def test_create_filter_wildcard_conditions(self, milvus_db):
"""Test filter creation with wildcard conditions."""
filters = {"user_id": "alice", "run_id": "*"}
filter_str = milvus_db._create_filter(filters)
assert 'metadata["user_id"] == "alice"' in filter_str
assert 'metadata["run_id"] == "*"' not in filter_str
def test_search_with_filters(self, milvus_db, mock_milvus_client):
"""Test search with metadata filters (reproduces user's bug scenario)."""
# Setup mock return value
@@ -337,10 +345,44 @@ class TestMilvusDB:
assert '(metadata["active"] == True)' in result
assert '(metadata["deleted"] == False)' in result
def test_update_omits_text_field_on_pre_v3_collection(self, mock_milvus_client):
"""update() must not include 'text' for collections without BM25 schema."""
mock_milvus_client.has_collection.return_value = True
mock_milvus_client.describe_collection.return_value = {
"fields": [
{"name": "id"},
{"name": "vectors"},
{"name": "metadata"},
]
}
db = MilvusDB(
url="http://localhost:19530",
token="test_token",
collection_name="legacy_collection",
embedding_model_dims=1536,
metric_type=MetricType.COSINE,
db_name="test_db",
)
assert db._has_bm25_schema is False
db.update(vector_id="id1", vector=[0.1] * 1536, payload={"data": "hello"})
upserted = mock_milvus_client.upsert.call_args[1]["data"]
assert "text" not in upserted
def test_update_includes_text_field_on_v3_collection(self, milvus_db, mock_milvus_client):
"""update() must include 'text' for collections with BM25 schema."""
assert milvus_db._has_bm25_schema is True
milvus_db.update(vector_id="id1", vector=[0.1] * 1536, payload={"data": "hello"})
upserted = mock_milvus_client.upsert.call_args[1]["data"]
assert upserted["text"] == "hello"
def test_collection_already_exists(self, mock_milvus_client):
"""Test that existing collection is not recreated."""
mock_milvus_client.has_collection.return_value = True
MilvusDB(
url="http://localhost:19530",
token="test_token",
@@ -349,7 +391,7 @@ class TestMilvusDB:
metric_type=MetricType.L2,
db_name="test_db"
)
# create_collection should not be called
mock_milvus_client.create_collection.assert_not_called()
+108 -1
View File
@@ -275,9 +275,116 @@ class TestNeptuneAnalyticsVectorInitValidation:
def test_rejects_injection_payload_in_init(self, payload, monkeypatch):
from mem0.vector_stores.neptune_analytics import NeptuneAnalyticsVector
monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None)
with pytest.raises(ValueError, match="Invalid collection_name"):
NeptuneAnalyticsVector(
endpoint="neptune-graph://test",
collection_name=payload
)
class _FakeNeptuneGraph:
"""Minimal stand-in for `NeptuneAnalyticsGraph.query()` so update()'s compensation
path can be exercised without a real Neptune Analytics endpoint."""
def __init__(self):
self.nodes = {}
self.fail_next_upsert = False
self.soft_fail_next_upsert = False
self.get_call_count = 0
def query(self, query_string, params=None):
params = params or {}
if "UNWIND $rows" in query_string:
rows = params["rows"]
if "CALL neptune.algo.vectors.upsert" in query_string:
return [{"success": True} for _ in rows]
for row in rows:
self.nodes[row["node_id"]] = dict(row["properties"])
return []
if "CALL neptune.algo.vectors.upsert" in query_string:
if self.fail_next_upsert:
self.fail_next_upsert = False
raise RuntimeError("simulated Neptune upsert failure")
if self.soft_fail_next_upsert:
self.soft_fail_next_upsert = False
return [{"success": False}]
return [{"success": True}]
if "SET n = $properties" in query_string:
self.nodes[params["vector_id"]] = dict(params["properties"])
return []
if "RETURN n" in query_string and "node_id" in params:
self.get_call_count += 1
vector_id = params["node_id"]
if vector_id not in self.nodes:
return []
return [{"n": {"~id": vector_id, "~properties": dict(self.nodes[vector_id])}}]
if "DETACH DELETE n" in query_string:
self.nodes.pop(params.get("node_id"), None)
return []
return []
class TestNeptuneAnalyticsUpdateRollback:
"""update() must not leave a payload committed against a stale embedding when the
vector upsert step fails. See the compensation logic in `NeptuneAnalyticsVector.update()`."""
def _make_vec(self, monkeypatch):
monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None)
vec = NeptuneAnalyticsVector(endpoint="neptune-graph://test", collection_name="rollback")
vec.graph = _FakeNeptuneGraph()
return vec
def test_restores_prior_payload_when_upsert_fails(self, monkeypatch):
vec = self._make_vec(monkeypatch)
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
vec.graph.fail_next_upsert = True
with pytest.raises(RuntimeError):
vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"})
restored = vec.get("A")
assert restored.payload["data"] == "alpha"
assert restored.payload["user_id"] == "u1"
def test_does_not_snapshot_prior_state_for_a_vector_only_update(self, monkeypatch):
"""Only a combined payload+vector update can desync -- a vector-only update has
nothing to roll back to, so it must skip the extra get() snapshot entirely."""
vec = self._make_vec(monkeypatch)
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
vec.graph.fail_next_upsert = True
calls_before = vec.graph.get_call_count
with pytest.raises(RuntimeError):
vec.update("A", vector=[0.9, 0.9])
assert vec.graph.get_call_count == calls_before
def test_succeeds_normally_when_upsert_does_not_fail(self, monkeypatch):
vec = self._make_vec(monkeypatch)
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"})
updated = vec.get("A")
assert updated.payload["data"] == "beta"
def test_rolls_back_on_soft_upsert_failure(self, monkeypatch):
"""A soft {"success": False} row desyncs the payload from the embedding just as much as a
thrown error, so update() must treat it as a failure and roll the payload back too."""
vec = self._make_vec(monkeypatch)
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
vec.graph.soft_fail_next_upsert = True
with pytest.raises(RuntimeError):
vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"})
restored = vec.get("A")
assert restored.payload["data"] == "alpha"
assert restored.payload["user_id"] == "u1"
+113 -3
View File
@@ -424,10 +424,25 @@ class TestOpenSearchDB(unittest.TestCase):
@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."""
"""Search errors should log with exc_info and re-raise (not swallow as [])."""
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, [])
with self.assertRaises(Exception):
self.os_db.search(query="", vectors=[[0.1] * 1536], top_k=5)
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_keyword_search_error_logs_and_degrades(self, mock_logger):
"""Keyword search errors should log with exc_info and degrade to None (not raise).
keyword_search() is a best-effort augmentation for search(); raising here
would crash the whole search() call on a keyword-only failure (regression
per maintainer review on #6519).
"""
self.client_mock.search.side_effect = Exception("Keyword search failed")
result = self.os_db.keyword_search(query="test", top_k=5)
self.assertIsNone(result)
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")
@@ -594,3 +609,98 @@ class TestOpenSearchFilterValidation(unittest.TestCase):
results = self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice"})
self.assertEqual(results, [])
self.client_mock.search.assert_called_once()
class TestOpenSearchCustomFilters(TestOpenSearchFilterValidation):
"""Custom (non-identity) filter keys must be honored, not silently dropped."""
def setUp(self):
super().setUp()
self.client_mock.search.return_value = {"hits": {"hits": []}}
def _search_body(self):
return self.client_mock.search.call_args[1]["body"]
def test_search_honors_custom_filter_key(self):
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "category": "billing"})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertIn({"term": {"payload.user_id.keyword": "alice"}}, clauses)
self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses)
def test_keyword_search_honors_custom_filter_key(self):
self.os_db.keyword_search(query="report", filters={"category": "billing"})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses)
def test_list_honors_custom_filter_key(self):
self.os_db.list(filters={"category": "billing"})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses)
def test_non_string_filter_values_use_plain_field(self):
"""Non-string identity/scalar values must match against the plain payload field, not .keyword."""
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"age": 30, "archived": False})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertIn({"term": {"payload.age": 30}}, clauses)
self.assertIn({"term": {"payload.archived": False}}, clauses)
def test_custom_filter_keys_still_validated(self):
with self.assertRaises(ValueError):
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"bad key!": "x"})
def test_search_ignores_or_operator_filter(self):
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "$or": [{"a": 1}]})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}])
def test_search_ignores_operator_shaped_filter_value(self):
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "score": {"gte": 5}})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}])
def test_search_translates_wildcard_filter_value_to_exists(self):
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "category": "*"})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertEqual(
clauses,
[
{"term": {"payload.user_id.keyword": "alice"}},
{"exists": {"field": "payload.category"}},
],
)
def test_list_ignores_or_operator_filter(self):
self.os_db.list(filters={"user_id": "alice", "$or": [{"a": 1}]})
self.client_mock.search.assert_called_once()
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}])
class TestOpenSearchWildcardFilters(TestOpenSearchFilterValidation):
"""The "*" wildcard means "any value" (a documented Platform pattern) and
must build an exists query — as opensearch.ts does — for every key,
including the identity keys, instead of a literal term match on "*"."""
def setUp(self):
super().setUp()
self.client_mock.search.return_value = {"hits": {"hits": []}}
def _search_body(self):
return self.client_mock.search.call_args[1]["body"]
def test_identity_key_wildcard_builds_exists_query(self):
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"agent_id": "*"})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertIn({"exists": {"field": "payload.agent_id"}}, clauses)
self.assertNotIn({"term": {"payload.agent_id.keyword": "*"}}, clauses)
def test_custom_key_wildcard_builds_exists_query(self):
self.os_db.list(filters={"category": "*"})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertIn({"exists": {"field": "payload.category"}}, clauses)
def test_wildcard_combines_with_term_filters(self):
self.os_db.keyword_search(query="q", filters={"user_id": "u1", "topic": "*"})
clauses = self._search_body()["query"]["bool"]["filter"]
self.assertIn({"term": {"payload.user_id.keyword": "u1"}}, clauses)
self.assertIn({"exists": {"field": "payload.topic"}}, clauses)
File diff suppressed because it is too large Load Diff
+41 -24
View File
@@ -711,7 +711,7 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [] # No existing collections
self.mock_cursor.fetchone.return_value = (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"})
self.mock_cursor.fetchone.return_value = (self.test_ids[0], {"key": "value1"})
pgvector = PGVector(
dbname="test_db",
@@ -734,7 +734,7 @@ class TestPGVector(unittest.TestCase):
# Verify get query was executed
get_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call)]
if "SELECT id, payload" in str(call)]
self.assertTrue(len(get_calls) > 0)
# Verify result
@@ -756,7 +756,7 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [] # No existing collections
self.mock_cursor.fetchone.return_value = (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"})
self.mock_cursor.fetchone.return_value = (self.test_ids[0], {"key": "value1"})
pgvector = PGVector(
dbname="test_db",
@@ -779,7 +779,7 @@ class TestPGVector(unittest.TestCase):
# Verify get query was executed
get_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call)]
if "SELECT id, payload" in str(call)]
self.assertTrue(len(get_calls) > 0)
# Verify result
@@ -1050,8 +1050,8 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
(self.test_ids[0], {"key": "value1"}),
(self.test_ids[1], {"key": "value2"}),
]
pgvector = PGVector(
@@ -1075,7 +1075,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call)]
if "SELECT id, payload" in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify result
@@ -1098,8 +1098,8 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
(self.test_ids[0], {"key": "value1"}),
(self.test_ids[1], {"key": "value2"}),
]
pgvector = PGVector(
@@ -1123,7 +1123,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call)]
if "SELECT id, payload" in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify result
@@ -1440,7 +1440,7 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice", "agent_id": "agent1"}),
(self.test_ids[0], {"user_id": "alice", "agent_id": "agent1"}),
]
pgvector = PGVector(
@@ -1465,7 +1465,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed with filters
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify results
@@ -1489,7 +1489,7 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice", "agent_id": "agent1"}),
(self.test_ids[0], {"user_id": "alice", "agent_id": "agent1"}),
]
pgvector = PGVector(
@@ -1514,7 +1514,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed with filters
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify results
@@ -1538,7 +1538,7 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice"}),
(self.test_ids[0], {"user_id": "alice"}),
]
pgvector = PGVector(
@@ -1563,7 +1563,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed with single filter
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify results
@@ -1586,7 +1586,7 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice"}),
(self.test_ids[0], {"user_id": "alice"}),
]
pgvector = PGVector(
@@ -1611,7 +1611,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed with single filter
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify results
@@ -1634,8 +1634,8 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
(self.test_ids[0], {"key": "value1"}),
(self.test_ids[1], {"key": "value2"}),
]
pgvector = PGVector(
@@ -1659,7 +1659,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed without WHERE clause
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call) and "WHERE" not in str(call)]
if "SELECT id, payload" in str(call) and "WHERE" not in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify results
@@ -1682,8 +1682,8 @@ class TestPGVector(unittest.TestCase):
mock_get_cursor.return_value.__exit__.return_value = None
self.mock_cursor.fetchall.return_value = [
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
(self.test_ids[0], {"key": "value1"}),
(self.test_ids[1], {"key": "value2"}),
]
pgvector = PGVector(
@@ -1707,7 +1707,7 @@ class TestPGVector(unittest.TestCase):
# Verify list query was executed without WHERE clause
list_calls = [call for call in self.mock_cursor.execute.call_args_list
if "SELECT id, vector, payload" in str(call) and "WHERE" not in str(call)]
if "SELECT id, payload" in str(call) and "WHERE" not in str(call)]
self.assertTrue(len(list_calls) > 0)
# Verify results
@@ -2508,3 +2508,20 @@ class TestBuildFilterConditions(unittest.TestCase):
def test_numeric_scalar_becomes_string(self):
conditions, params = _build_filter_conditions({"priority": 42})
self.assertEqual(params, ["priority", "42"])
def test_in_rejects_string_value(self):
"""Passing a string to 'in' would iterate characters and produce a misleading ANY() clause."""
with self.assertRaises(ValueError, msg="Expected ValueError for non-list 'in' value"):
_build_filter_conditions({"user_id": {"in": "alice"}})
def test_in_rejects_dict_value(self):
with self.assertRaises(ValueError):
_build_filter_conditions({"user_id": {"in": {"$gt": 0}}})
def test_nin_rejects_string_value(self):
with self.assertRaises(ValueError):
_build_filter_conditions({"user_id": {"nin": "alice"}})
def test_in_accepts_list_value(self):
conditions, params = _build_filter_conditions({"user_id": {"in": ["alice", "bob"]}})
self.assertEqual(params, ["user_id", ["alice", "bob"]])
+44 -2
View File
@@ -139,9 +139,40 @@ def test_list_cols(pinecone_db):
pinecone_db.client.list_indexes.assert_called()
def test_delete_col(pinecone_db):
@pytest.mark.parametrize(
"namespace,drops_whole_index",
[(None, True), ("test_namespace", False), ("", False)],
)
def test_delete_col_scopes_to_namespace(pinecone_db, namespace, drops_whole_index):
pinecone_db.namespace = namespace
pinecone_db.delete_col()
pinecone_db.client.delete_index.assert_called_with("test_index")
if drops_whole_index:
pinecone_db.client.delete_index.assert_called_with("test_index")
else:
pinecone_db.index.delete.assert_called_with(delete_all=True, namespace=namespace)
pinecone_db.client.delete_index.assert_not_called()
def test_delete_col_namespace_delete_error_is_swallowed(pinecone_db):
pinecone_db.index.delete.side_effect = Exception("Namespace not found")
pinecone_db.delete_col()
def test_reset_with_namespace_does_not_drop_or_recreate_index(pinecone_db, mock_pinecone_client):
mock_pinecone_client.list_indexes.return_value.names.return_value = ["test_index"]
mock_pinecone_client.create_index.reset_mock()
pinecone_db.reset()
pinecone_db.index.delete.assert_called_with(delete_all=True, namespace="test_namespace")
mock_pinecone_client.delete_index.assert_not_called()
mock_pinecone_client.create_index.assert_not_called()
def test_reset_without_namespace_drops_and_recreates_index(pinecone_db, mock_pinecone_client):
pinecone_db.namespace = None
mock_pinecone_client.list_indexes.return_value.names.return_value = []
pinecone_db.reset()
mock_pinecone_client.delete_index.assert_called_with("test_index")
mock_pinecone_client.create_index.assert_called()
def test_col_info(pinecone_db):
@@ -190,6 +221,17 @@ def test_count_with_none_vector_count(pinecone_db):
pinecone_db.index.describe_index_stats.assert_called_once()
def test_count_with_empty_string_namespace(pinecone_db):
pinecone_db.namespace = ""
stats_mock = MagicMock()
stats_mock.namespaces = {"": MagicMock(vector_count=3)}
stats_mock.total_vector_count = 99
pinecone_db.index.describe_index_stats.return_value = stats_mock
count = pinecone_db.count()
assert count == 3
def test_list_error_returns_list_not_dict(pinecone_db):
"""list() error path must return [[]] so callers can do result[0]."""
pinecone_db.index.query.side_effect = Exception("connection error")
+24
View File
@@ -54,6 +54,30 @@ class TestQdrant(unittest.TestCase):
)
self.assertTrue(os.path.isfile(sentinel))
def test_reset_clears_points_on_local_qdrant(self):
"""#6411: reset() must drop points even when the collection dir survives delete_col()."""
with tempfile.TemporaryDirectory() as tmp:
store = Qdrant(collection_name="reset_me", embedding_model_dims=4, path=os.path.join(tmp, "qdrant"))
try:
self.assertTrue(store.is_local)
store.insert(
vectors=[[0.1, 0.2, 0.3, 0.4]],
payloads=[{"data": "remember me"}],
ids=[str(uuid.uuid4())],
)
self.assertEqual(len(store.list(top_k=10)[0]), 1)
# Simulate Windows/NFS, where rmtree(ignore_errors=True) silently fails
# on the open sqlite handle and the collection dir survives delete_col().
with patch("qdrant_client.local.qdrant_local.shutil.rmtree"):
store.reset()
self.assertEqual(len(store.list(top_k=10)[0]), 0)
sparse = store.client.get_collection("reset_me").config.params.sparse_vectors
self.assertIn("bm25", sparse or {})
finally:
store.client.close()
def test_create_col(self):
self.client_mock.get_collections.return_value = MagicMock(collections=[])
@@ -239,9 +239,23 @@ REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
def _redis_search_available(host, port, timeout=2):
"""RediSearch (the FT.* command family) is an optional module, not part of vanilla Redis."""
if not _tcp_reachable(host, port, timeout=timeout):
return False
try:
import redis
client = redis.Redis(host=host, port=port, socket_connect_timeout=timeout, socket_timeout=timeout)
modules = {m["name"].lower() for m in client.module_list()}
return "search" in modules
except Exception:
return False
@pytest.mark.skipif(
not _tcp_reachable(REDIS_HOST, REDIS_PORT),
reason=f"Redis not reachable at {REDIS_HOST}:{REDIS_PORT}",
not _redis_search_available(REDIS_HOST, REDIS_PORT),
reason=f"RediSearch module not available at {REDIS_HOST}:{REDIS_PORT} (requires Redis Stack, not vanilla Redis)",
)
class TestRedis:
"""Redis returns cosine distance [0,2]. Conversion: score = max(0, 1 - dist)."""
+55 -8
View File
@@ -1,9 +1,10 @@
from unittest.mock import Mock, patch
import logging
from unittest.mock import MagicMock, Mock, patch
import pytest
from mem0.configs.vector_stores.supabase import IndexMeasure, IndexMethod
from mem0.vector_stores.supabase import Supabase
from mem0.vector_stores.supabase import VECS_MAX_QUERY_LIMIT, Supabase
@pytest.fixture
@@ -14,13 +15,12 @@ def mock_vecs_client():
@pytest.fixture
def mock_collection():
collection = Mock()
collection = MagicMock()
collection.name = "test_collection"
collection.vectors = 100
collection.dimension = 1536
collection.index_method = "hnsw"
collection.distance_metric = "cosine_distance"
collection.describe.return_value = collection
collection.index = "ix_vector_cosine_ops_hnsw_m16_efc64_abc1234"
collection.is_indexed_for_measure.return_value = True
collection.__len__.return_value = 100
return collection
@@ -141,10 +141,57 @@ def test_col_info(supabase_instance, mock_collection):
"name": "test_collection",
"count": 100,
"dimension": 1536,
"index": {"method": "hnsw", "metric": "cosine_distance"},
"index": {
"name": "ix_vector_cosine_ops_hnsw_m16_efc64_abc1234",
"measure": "cosine_distance",
"is_indexed": True,
},
}
def test_search_clamps_top_k_to_vecs_limit(supabase_instance, mock_collection, caplog):
mock_collection.query.return_value = []
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.supabase"):
supabase_instance.search(query="", vectors=[[0.1, 0.2, 0.3]], top_k=5000)
mock_collection.query.assert_called_once_with(
data=[[0.1, 0.2, 0.3]], limit=VECS_MAX_QUERY_LIMIT, filters=None, include_metadata=True, include_value=True
)
assert "5000" in caplog.text
assert str(VECS_MAX_QUERY_LIMIT) in caplog.text
def test_search_does_not_clamp_top_k_within_limit(supabase_instance, mock_collection):
mock_collection.query.return_value = []
supabase_instance.search(query="", vectors=[[0.1, 0.2, 0.3]], top_k=5)
mock_collection.query.assert_called_once_with(
data=[[0.1, 0.2, 0.3]], limit=5, filters=None, include_metadata=True, include_value=True
)
def test_list_clamps_top_k_to_vecs_limit(supabase_instance, mock_collection, caplog):
mock_collection.query.return_value = []
mock_collection.fetch.return_value = []
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.supabase"):
supabase_instance.list(top_k=5000)
_, kwargs = mock_collection.query.call_args
assert kwargs["limit"] == VECS_MAX_QUERY_LIMIT
assert "5000" in caplog.text
assert str(VECS_MAX_QUERY_LIMIT) in caplog.text
def test_col_info_does_not_raise(supabase_instance):
info = supabase_instance.col_info()
assert isinstance(info, dict)
assert info["name"] == "test_collection"
def test_preprocess_filters(supabase_instance):
# Test single filter
single_filter = {"category": "test"}
+52 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from mem0.configs.vector_stores.upstash_vector import UpstashVectorConfig
from mem0.vector_stores.upstash_vector import UpstashVector
from mem0.vector_stores.upstash_vector import UpstashVector, _validate_filter
@dataclass
@@ -228,6 +228,57 @@ def test_update_vector_with_embeddings(upstash_instance_with_embeddings):
)
def test_filter_rejects_dict_value():
with pytest.raises(ValueError):
_validate_filter("user_id", {"$ne": ""})
def test_filter_rejects_list_value():
with pytest.raises(ValueError):
_validate_filter("user_id", ["alice", "bob"])
def test_filter_rejects_invalid_key():
with pytest.raises(ValueError):
_validate_filter("user_id; DROP", "alice")
def test_filter_accepts_scalars():
_validate_filter("user_id", "alice")
_validate_filter("count", 42)
_validate_filter("score", 0.95)
_validate_filter("active", True)
def test_filter_rejects_double_quote_in_value():
with pytest.raises(ValueError, match="prohibited characters"):
_validate_filter("user_id", 'alice" OR 1=1 --')
def test_filter_rejects_backslash_in_value():
with pytest.raises(ValueError, match="prohibited characters"):
_validate_filter("user_id", "alice\\bob")
def test_search_rejects_dict_filter(upstash_instance):
with pytest.raises(ValueError):
upstash_instance.search(
query="test", vectors=[[0.1]], filters={"user_id": {"$ne": ""}}
)
def test_keyword_search_raises_on_invalid_filter(upstash_instance):
with pytest.raises(ValueError):
upstash_instance.keyword_search(
query="test", filters={"user_id": 'alice" OR 1=1'}
)
def test_filter_rejects_key_with_trailing_newline():
with pytest.raises(ValueError, match="Invalid filter key"):
_validate_filter("user_id\n", "alice")
def test_insert_vectors_with_embeddings_missing_data(upstash_instance_with_embeddings):
vectors = [[0.1, 0.2, 0.3]]
payloads = [{"name": "vector1"}] # Missing data field
+44
View File
@@ -220,6 +220,50 @@ class TestWeaviateDB(unittest.TestCase):
self.client_mock.collections.delete.assert_called_once_with("test_collection")
self.client_mock.collections.create.assert_called_once()
def test_update_preserves_properties_and_does_not_write_model_fields(self):
# Regression: the vector branch of update() previously resent
# dict(OutputData) as properties, i.e. the model field names
# {"id", "score", "payload"}, corrupting the stored object instead of
# preserving its real properties.
valid_uuid = str(uuid.uuid4())
collection = self.client_mock.collections.get.return_value
# Mock get() so that, on the buggy code path, the vector branch can run
# to completion and actually write its (wrong) properties.
mock_response = MagicMock()
mock_response.properties = {"data": "existing", "hash": "abc123"}
mock_response.uuid = valid_uuid
collection.query.fetch_object_by_id.return_value = mock_response
new_payload = {
"data": "updated memory",
"hash": "def456",
"user_id": "user_123",
}
self.weaviate_db.update(
vector_id=valid_uuid,
vector=[0.1] * 1536,
payload=new_payload,
)
update_calls = collection.data.update.call_args_list
# The payload branch updates the real properties.
property_updates = [c for c in update_calls if "properties" in c.kwargs]
self.assertEqual(len(property_updates), 1)
self.assertEqual(property_updates[0].kwargs["properties"], new_payload)
# No update call may write the OutputData model field names as properties.
for call in update_calls:
props = call.kwargs.get("properties", {})
self.assertNotIn("score", props)
self.assertNotIn("payload", props)
# The vector branch updates the vector without touching properties.
vector_updates = [c for c in update_calls if "vector" in c.kwargs]
self.assertEqual(len(vector_updates), 1)
self.assertNotIn("properties", vector_updates[0].kwargs)
if __name__ == "__main__":
unittest.main()