Files
mem0ai--mem0/tests/test_server_params.py
wehub-resource-sync 555e282cc4
ci / changelog_check (push) Waiting to run
ci / check_changes (push) Waiting to run
ci / build_mem0 (3.10) (push) Blocked by required conditions
ci / build_mem0 (3.11) (push) Blocked by required conditions
ci / build_mem0 (3.12) (push) Blocked by required conditions
CLI Node CI / lint (push) Waiting to run
CLI Node CI / test (20) (push) Waiting to run
CLI Node CI / test (22) (push) Waiting to run
CLI Node CI / build (push) Waiting to run
CLI Python CI / lint (push) Waiting to run
CLI Python CI / test (3.10) (push) Waiting to run
CLI Python CI / test (3.11) (push) Waiting to run
CLI Python CI / test (3.12) (push) Waiting to run
CLI Python CI / build (push) Waiting to run
openclaw checks / lint (push) Waiting to run
openclaw checks / test (20) (push) Waiting to run
openclaw checks / test (22) (push) Waiting to run
openclaw checks / build (push) Waiting to run
opencode-plugin checks / build (push) Waiting to run
pi-agent-plugin checks / lint (push) Waiting to run
pi-agent-plugin checks / test (20) (push) Waiting to run
pi-agent-plugin checks / test (22) (push) Waiting to run
pi-agent-plugin checks / build (push) Waiting to run
TypeScript SDK CI / check_changes (push) Waiting to run
TypeScript SDK CI / changelog_check (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (22) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (22) (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

780 lines
32 KiB
Python

"""Tests for REST API parameter forwarding.
Verifies that the Pydantic request models in server/main.py correctly accept
and forward all parameters supported by the underlying Memory class methods,
including top_k, threshold, infer, memory_type, and prompt — which were
previously silently dropped by Pydantic v2's default extra='ignore' behavior.
"""
import importlib
import os
from unittest.mock import MagicMock, patch
import pytest
from mem0.exceptions import ValidationError as Mem0ValidationError
pytest.importorskip("fastapi", reason="fastapi not installed")
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def _mock_memory():
"""Patch Memory.from_config so the server imports without a real backend."""
mock_instance = MagicMock()
mock_instance.add.return_value = {"results": [{"id": "mem-1", "event": "ADD", "memory": "test"}]}
mock_instance.search.return_value = [{"id": "mem-1", "memory": "test", "score": 0.9}]
mock_instance.get.return_value = {"id": "mem-1", "memory": "test memory"}
mock_instance.get_all.return_value = [{"id": "mem-1", "memory": "test memory"}]
mock_instance.update.return_value = {"message": "Memory updated"}
mock_instance.history.return_value = [{"id": "mem-1", "old_memory": "a", "new_memory": "b"}]
mock_instance.delete.return_value = None
mock_instance.delete_all.return_value = {"message": "Memories deleted"}
mock_instance.reset.return_value = None
with patch.dict(os.environ, {"OPENAI_API_KEY": "fake-key", "ADMIN_API_KEY": ""}):
with patch("mem0.Memory.from_config", return_value=mock_instance):
yield mock_instance
@pytest.fixture
def client(_mock_memory):
"""Return a TestClient wired to the server app with mocked Memory."""
import server.main as server_main
with patch.dict(os.environ, {"ADMIN_API_KEY": ""}):
importlib.reload(server_main)
return TestClient(server_main.app)
@pytest.fixture
def mock_memory(_mock_memory):
return _mock_memory
# ===========================================================================
# SearchRequest: top_k parameter
# ===========================================================================
class TestSearchLimit:
"""Verify that the top_k parameter is accepted and forwarded to Memory.search()."""
def test_limit_forwarded(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "top_k": 5})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["top_k"] == 5
def test_limit_one(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "top_k": 1})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["top_k"] == 1
def test_limit_omitted_uses_memory_default(self, client, mock_memory):
"""When top_k is not sent, it should not appear in the kwargs,
allowing Memory.search() to use its own default (100)."""
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "top_k" not in kwargs
# ===========================================================================
# SearchRequest: threshold parameter
# ===========================================================================
class TestSearchThreshold:
"""Verify that the threshold parameter is accepted and forwarded."""
def test_threshold_forwarded(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "threshold": 0.8})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["threshold"] == 0.8
def test_threshold_zero(self, client, mock_memory):
"""threshold=0.0 is a valid falsy value that must not be filtered out."""
resp = client.post("/search", json={"query": "food", "user_id": "u1", "threshold": 0.0})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["threshold"] == 0.0
def test_threshold_omitted_uses_memory_default(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "threshold" not in kwargs
# ===========================================================================
# SearchRequest: explain parameter
# ===========================================================================
class TestSearchExplain:
"""Verify that the explain parameter is accepted and forwarded."""
def test_explain_true_forwarded(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "explain": True})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["explain"] is True
def test_explain_false_forwarded(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1", "explain": False})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["explain"] is False
def test_explain_omitted_uses_memory_default(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "explain" not in kwargs
# ===========================================================================
# SearchRequest: top_k + threshold together
# ===========================================================================
class TestSearchLimitAndThreshold:
def test_both_forwarded(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "top_k": 10, "threshold": 0.5
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["top_k"] == 10
assert kwargs["threshold"] == 0.5
# ===========================================================================
# MemoryCreate: infer parameter
# ===========================================================================
class TestAddInfer:
"""Verify that the infer parameter is accepted and forwarded to Memory.add()."""
def test_infer_false_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "Store this exactly"}],
"user_id": "u1",
"infer": False,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is False
def test_infer_true_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"infer": True,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is True
def test_infer_omitted_uses_memory_default(self, client, mock_memory):
"""When infer is not sent, it should not appear in kwargs,
allowing Memory.add() to use its own default (True)."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hello"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "infer" not in kwargs
# ===========================================================================
# MemoryCreate: memory_type parameter
# ===========================================================================
class TestAddMemoryType:
"""Verify that the memory_type parameter is accepted and forwarded."""
def test_memory_type_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"memory_type": "core",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["memory_type"] == "core"
def test_memory_type_omitted(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hello"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "memory_type" not in kwargs
# ===========================================================================
# MemoryCreate: prompt parameter
# ===========================================================================
class TestAddPrompt:
"""Verify that the prompt parameter is accepted and forwarded."""
def test_prompt_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"prompt": "Extract food preferences only.",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["prompt"] == "Extract food preferences only."
def test_prompt_omitted(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hello"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "prompt" not in kwargs
# ===========================================================================
# MemoryCreate: all new params together
# ===========================================================================
class TestAddAllNewParams:
def test_infer_memory_type_and_prompt_together(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "I like pizza"}],
"user_id": "u1",
"infer": False,
"memory_type": "core",
"prompt": "Custom extraction prompt.",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is False
assert kwargs["memory_type"] == "core"
assert kwargs["prompt"] == "Custom extraction prompt."
# ===========================================================================
# Edge cases: falsy-but-valid values must not be filtered out
# ===========================================================================
class TestFalsyValues:
"""The handler filters with `v is not None`. Falsy values like False, 0,
0.0, and empty string must still be forwarded."""
def test_infer_false_not_filtered(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": False,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is False
def test_threshold_zero_not_filtered(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "threshold": 0.0,
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["threshold"] == 0.0
# ===========================================================================
# Extra/unknown fields are still silently ignored (existing Pydantic behavior)
# ===========================================================================
class TestUnknownFieldsIgnored:
def test_unknown_search_field_ignored(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "bogus_field": "xyz",
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "bogus_field" not in kwargs
def test_unknown_add_field_ignored(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"unknown_param": 42,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "unknown_param" not in kwargs
# ===========================================================================
# Backward compatibility: existing params still work
# ===========================================================================
class TestExistingParamsUnchanged:
def test_search_filters_still_forwarded(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food",
"user_id": "u1",
"agent_id": "a1",
"filters": {"category": "food"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["filters"]["user_id"] == "u1"
assert kwargs["filters"]["agent_id"] == "a1"
assert kwargs["filters"]["category"] == "food"
def test_add_metadata_still_forwarded(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"agent_id": "a1",
"metadata": {"source": "test"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["user_id"] == "u1"
assert kwargs["agent_id"] == "a1"
assert kwargs["metadata"] == {"source": "test"}
# ===========================================================================
# OpenAPI schema: new fields are documented
# ===========================================================================
class TestOpenAPISchema:
"""Verify the new fields appear in the auto-generated OpenAPI schema."""
def test_search_schema_includes_limit(self, client):
schema = client.get("/openapi.json").json()
search_props = schema["components"]["schemas"]["SearchRequest"]["properties"]
assert "top_k" in search_props
assert search_props["top_k"]["description"] == "Maximum number of results to return."
def test_search_schema_includes_threshold(self, client):
schema = client.get("/openapi.json").json()
search_props = schema["components"]["schemas"]["SearchRequest"]["properties"]
assert "threshold" in search_props
def test_add_schema_includes_infer(self, client):
schema = client.get("/openapi.json").json()
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
assert "infer" in add_props
def test_add_schema_includes_memory_type(self, client):
schema = client.get("/openapi.json").json()
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
assert "memory_type" in add_props
def test_add_schema_includes_prompt(self, client):
schema = client.get("/openapi.json").json()
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
assert "prompt" in add_props
# ===========================================================================
# Pydantic type validation: invalid types return 422
# ===========================================================================
class TestTypeValidation:
"""Verify FastAPI/Pydantic rejects invalid types with 422."""
def test_limit_string_rejected(self, client):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "top_k": "not_a_number",
})
assert resp.status_code == 422
def test_threshold_string_rejected(self, client):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "threshold": "high",
})
assert resp.status_code == 422
def test_infer_string_coerced_by_pydantic(self, client, mock_memory):
"""Pydantic v2 coerces truthy strings like 'yes' to True for bool fields."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": "yes",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert kwargs["infer"] is True
def test_infer_invalid_value_rejected(self, client):
"""A value that cannot be coerced to bool should be rejected."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": [1, 2, 3],
})
assert resp.status_code == 422
def test_limit_float_rejected(self, client):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "top_k": 5.7,
})
assert resp.status_code == 422
def test_memory_type_int_rejected(self, client):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"memory_type": 123,
})
assert resp.status_code == 422
# ===========================================================================
# Explicit null values: treated as omitted (filtered by `is not None`)
# ===========================================================================
class TestExplicitNull:
"""When a client sends null for an optional field, it should be treated
as omitted — the Memory class default should be used."""
def test_limit_null_uses_memory_default(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "top_k": None,
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "top_k" not in kwargs
def test_infer_null_uses_memory_default(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"infer": None,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "infer" not in kwargs
def test_prompt_null_uses_memory_default(self, client, mock_memory):
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "test"}],
"user_id": "u1",
"prompt": None,
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
assert "prompt" not in kwargs
# ===========================================================================
# Verify exact call signatures match Memory method params
# ===========================================================================
class TestCallSignatureMatch:
"""Ensure forwarded params exactly match Memory.add() and Memory.search()
keyword argument names — a typo here would cause a TypeError at runtime."""
def test_search_kwargs_are_valid(self, client, mock_memory):
"""All kwargs forwarded to Memory.search() must be in its signature."""
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "agent_id": "a1",
"run_id": "r1", "filters": {"k": "v"},
"top_k": 10, "threshold": 0.5,
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
valid_params = {"query", "top_k", "filters", "threshold", "rerank"}
for key in kwargs:
assert key in valid_params, f"Unexpected kwarg '{key}' forwarded to Memory.search()"
assert kwargs["filters"]["user_id"] == "u1"
assert kwargs["filters"]["agent_id"] == "a1"
assert kwargs["filters"]["run_id"] == "r1"
assert kwargs["filters"]["k"] == "v"
def test_add_kwargs_are_valid(self, client, mock_memory):
"""All kwargs forwarded to Memory.add() must be in its signature."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hi"}],
"user_id": "u1", "agent_id": "a1", "run_id": "r1",
"metadata": {"k": "v"},
"infer": False, "memory_type": "core", "prompt": "custom",
})
assert resp.status_code == 200
# The handler passes messages= as a keyword arg, so it appears in kwargs too
_, kwargs = mock_memory.add.call_args
valid_params = {"messages", "user_id", "agent_id", "run_id", "metadata", "infer", "memory_type", "prompt"}
for key in kwargs:
assert key in valid_params, f"Unexpected kwarg '{key}' forwarded to Memory.add()"
def test_messages_excluded_from_params_dict(self, client, mock_memory):
"""messages is passed separately via messages= kwarg, not duplicated from model_dump."""
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hi"}],
"user_id": "u1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.add.call_args
# messages should be present (passed explicitly) and be a list of dicts
assert "messages" in kwargs
assert isinstance(kwargs["messages"], list)
assert kwargs["messages"][0] == {"role": "user", "content": "hi"}
def test_query_passed_explicitly(self, client, mock_memory):
"""query is passed as an explicit keyword arg to Memory.search()."""
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["query"] == "food"
# ===========================================================================
# MemoryUpdate: text and metadata forwarding (fix for #3933)
# ===========================================================================
class TestUpdateMemory:
"""Verify that PUT /memories/{id} extracts text and metadata from the
request body and forwards them correctly to Memory.update()."""
def test_text_forwarded_as_data(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={"text": "Likes tennis"})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert kwargs["data"] == "Likes tennis"
def test_metadata_forwarded(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={
"text": "Likes tennis",
"metadata": {"category": "sports"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert kwargs["metadata"] == {"category": "sports"}
def test_metadata_omitted_passes_none(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={"text": "Likes tennis"})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert "metadata" not in kwargs
def test_expiration_date_forwarded_without_text(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={"expiration_date": "2999-01-01"})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert kwargs["expiration_date"] == "2999-01-01"
assert "data" not in kwargs
def test_null_expiration_date_forwarded_for_clear(self, client, mock_memory):
resp = client.put("/memories/mem-1", json={"expiration_date": None})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert kwargs["expiration_date"] is None
def test_dict_not_passed_as_data(self, client, mock_memory):
"""Regression test for #3933: the entire dict must NOT be passed as data."""
resp = client.put("/memories/mem-1", json={"text": "updated content"})
assert resp.status_code == 200
_, kwargs = mock_memory.update.call_args
assert isinstance(kwargs["data"], str)
class TestUpdateOpenAPISchema:
"""Verify the MemoryUpdate schema appears in the OpenAPI docs."""
def test_update_schema_includes_text(self, client):
schema = client.get("/openapi.json").json()
update_props = schema["components"]["schemas"]["MemoryUpdate"]["properties"]
assert "text" in update_props
def test_update_schema_includes_metadata(self, client):
schema = client.get("/openapi.json").json()
update_props = schema["components"]["schemas"]["MemoryUpdate"]["properties"]
assert "metadata" in update_props
# ===========================================================================
# GetMemories: Entity parameters to filters mapping (fix for #4955)
# ===========================================================================
class TestGetMemories:
"""Verify that GET /memories correctly maps entity parameters to the filters dict."""
def test_get_memories_entity_filters_routing(self, client, mock_memory):
"""
Issue #4955: Test that the GET /memories route correctly handles
top-level entity parameters by mapping them to the filters dictionary
instead of passing them as direct kwargs to get_all()
"""
# Send a request with a valid top-level entity parameter
response = client.get("/memories?user_id=test_routing_user")
# 1. Verify the endpoint doesn't crash with a 500 error
assert response.status_code == 200
# 2. Verify the response is structured correctly
data = response.json()
assert isinstance(data, list)
# 3. Verify the core logic: the param was mapped to the filters dict!
_, kwargs = mock_memory.get_all.call_args
assert kwargs["filters"] == {"user_id": "test_routing_user"}
assert "top_k" not in kwargs
def test_get_memories_entity_filters_forward_top_k(self, client, mock_memory):
response = client.get("/memories?user_id=test_routing_user&top_k=1000")
assert response.status_code == 200
_, kwargs = mock_memory.get_all.call_args
assert kwargs["filters"] == {"user_id": "test_routing_user"}
assert kwargs["top_k"] == 1000
def test_get_memories_admin_top_k_zero_not_defaulted(self, client, mock_memory):
mock_memory.vector_store.list.return_value = []
response = client.get("/memories?top_k=0")
assert response.status_code == 200
_, kwargs = mock_memory.vector_store.list.call_args
assert kwargs["top_k"] == 0
def test_get_memories_rejects_top_k_above_limit(self, client, mock_memory):
response = client.get("/memories?user_id=test_routing_user&top_k=1001")
assert response.status_code == 422
mock_memory.get_all.assert_not_called()
# ===========================================================================
# SearchRequest: entity IDs mapped into filters (fix for server 502)
# ===========================================================================
class TestSearchEntityIdMapping:
"""Verify that POST /search maps top-level user_id / agent_id / run_id
into the filters dict instead of forwarding them as kwargs, which would
cause Memory.search() to raise ValueError in v3."""
def test_user_id_mapped_to_filters(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "user_id" not in kwargs
assert kwargs["filters"]["user_id"] == "u1"
def test_agent_id_mapped_to_filters(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "agent_id": "a1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "agent_id" not in kwargs
assert kwargs["filters"]["agent_id"] == "a1"
def test_run_id_mapped_to_filters(self, client, mock_memory):
resp = client.post("/search", json={"query": "food", "run_id": "r1"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert "run_id" not in kwargs
assert kwargs["filters"]["run_id"] == "r1"
def test_all_entity_ids_mapped(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food", "user_id": "u1", "agent_id": "a1", "run_id": "r1",
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["filters"] == {"user_id": "u1", "agent_id": "a1", "run_id": "r1"}
def test_entity_ids_merged_with_explicit_filters(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food",
"user_id": "u1",
"filters": {"category": "food"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["filters"]["user_id"] == "u1"
assert kwargs["filters"]["category"] == "food"
def test_no_entity_ids_no_filters(self, client, mock_memory):
resp = client.post("/search", json={"query": "food"})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["filters"] == {}
def test_only_filters_no_entity_ids(self, client, mock_memory):
resp = client.post("/search", json={
"query": "food",
"filters": {"user_id": "u1", "category": "food"},
})
assert resp.status_code == 200
_, kwargs = mock_memory.search.call_args
assert kwargs["filters"]["user_id"] == "u1"
assert kwargs["filters"]["category"] == "food"
class TestSearchValidationErrors:
"""Verify that ValueError from Memory.search() returns 400, not 502."""
def test_empty_filters_returns_400(self, client, mock_memory):
mock_memory.search.side_effect = ValueError(
"filters must contain at least one of: user_id, agent_id, run_id"
)
resp = client.post("/search", json={"query": "food", "filters": {}})
assert resp.status_code == 400
assert "filters must contain" in resp.json()["detail"]
def test_no_identifiers_returns_400(self, client, mock_memory):
mock_memory.search.side_effect = ValueError(
"filters must contain at least one of: user_id, agent_id, run_id"
)
resp = client.post("/search", json={"query": "food"})
assert resp.status_code == 400
# ===========================================================================
# add / update / delete: map core errors to 4xx instead of 502
# ===========================================================================
class TestWriteHandlerErrorMapping:
"""ValueError("... not found") -> 404, other ValueError / Mem0ValidationError
-> 400. A real outage still surfaces as 502 via upstream_error()."""
def test_update_not_found_returns_404(self, client, mock_memory):
mock_memory.update.side_effect = ValueError("Memory with id mem-1 not found")
resp = client.put("/memories/mem-1", json={"text": "new"})
assert resp.status_code == 404
assert "not found" in resp.json()["detail"]
def test_delete_not_found_returns_404(self, client, mock_memory):
mock_memory.delete.side_effect = ValueError("Memory with id mem-1 not found")
resp = client.delete("/memories/mem-1")
assert resp.status_code == 404
def test_update_other_value_error_returns_400(self, client, mock_memory):
mock_memory.update.side_effect = ValueError("data must be a non-empty string")
resp = client.put("/memories/mem-1", json={"text": "new"})
assert resp.status_code == 400
def test_add_validation_error_returns_400(self, client, mock_memory):
mock_memory.add.side_effect = Mem0ValidationError(
message="messages must be str, dict, or list[dict]", error_code="VALIDATION_003"
)
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hi"}], "user_id": "u1",
})
assert resp.status_code == 400
def test_add_real_outage_still_returns_502(self, client, mock_memory):
mock_memory.add.side_effect = RuntimeError("vector store unreachable")
resp = client.post("/memories", json={
"messages": [{"role": "user", "content": "hi"}], "user_id": "u1",
})
assert resp.status_code == 502