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
780 lines
32 KiB
Python
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
|