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
392 lines
17 KiB
Python
392 lines
17 KiB
Python
"""Tests for MemoryClient entity parameter rejection."""
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import requests
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_memory_client():
|
|
"""Create a mock MemoryClient for testing entity param rejection."""
|
|
with patch("mem0.client.main.httpx.Client") as mock_httpx:
|
|
# Create a mock client instance
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.get.return_value = MagicMock(
|
|
json=lambda: {"org_id": "org1", "project_id": "proj1", "user_email": "test@test.com"},
|
|
raise_for_status=lambda: None
|
|
)
|
|
mock_httpx.return_value = mock_http_client
|
|
|
|
with patch("mem0.client.main.capture_client_event"):
|
|
from mem0.client.main import MemoryClient
|
|
client = MemoryClient(api_key="test-api-key")
|
|
yield client
|
|
|
|
|
|
class TestSearchEntityParamRejection:
|
|
"""Tests that top-level entity params are rejected in search()."""
|
|
|
|
@pytest.mark.parametrize("query", ["", " ", "\n\t"])
|
|
def test_search_rejects_empty_query(self, mock_memory_client, query):
|
|
"""search() should reject empty or whitespace-only queries before API calls."""
|
|
with pytest.raises(ValueError, match="Invalid query.*empty or whitespace-only"):
|
|
mock_memory_client.search(query, filters={"user_id": "u1"})
|
|
|
|
mock_memory_client.client.post.assert_not_called()
|
|
|
|
def test_search_trims_query_before_api_call(self, mock_memory_client):
|
|
"""search() should send the normalized query to the API."""
|
|
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.search(" test query ", filters={"user_id": "u1"})
|
|
|
|
mock_memory_client.client.post.assert_called_once_with(
|
|
"/v3/memories/search/",
|
|
json={"query": "test query", "filters": {"user_id": "u1"}},
|
|
)
|
|
|
|
def test_search_passes_show_expired(self, mock_memory_client):
|
|
"""search() should pass show_expired to the API."""
|
|
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.search("test query", filters={"user_id": "u1"}, show_expired=True)
|
|
|
|
mock_memory_client.client.post.assert_called_once_with(
|
|
"/v3/memories/search/",
|
|
json={"query": "test query", "filters": {"user_id": "u1"}, "show_expired": True},
|
|
)
|
|
|
|
def test_search_rejects_user_id_kwarg(self, mock_memory_client):
|
|
"""search() should reject user_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"user_id"):
|
|
mock_memory_client.search("test query", user_id="u1")
|
|
|
|
def test_search_rejects_agent_id_kwarg(self, mock_memory_client):
|
|
"""search() should reject agent_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"agent_id"):
|
|
mock_memory_client.search("test query", agent_id="a1")
|
|
|
|
def test_search_rejects_app_id_kwarg(self, mock_memory_client):
|
|
"""search() should reject app_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"app_id"):
|
|
mock_memory_client.search("test query", app_id="app1")
|
|
|
|
def test_search_rejects_run_id_kwarg(self, mock_memory_client):
|
|
"""search() should reject run_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"run_id"):
|
|
mock_memory_client.search("test query", run_id="r1")
|
|
|
|
def test_search_rejects_multiple_entity_params(self, mock_memory_client):
|
|
"""search() should reject multiple top-level entity params."""
|
|
with pytest.raises(ValueError, match=r"user_id|agent_id"):
|
|
mock_memory_client.search("test query", user_id="u1", agent_id="a1")
|
|
|
|
|
|
class TestGetAllEntityParamRejection:
|
|
"""Tests that top-level entity params are rejected in get_all()."""
|
|
|
|
def test_get_all_rejects_user_id_kwarg(self, mock_memory_client):
|
|
"""get_all() should reject user_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"user_id"):
|
|
mock_memory_client.get_all(user_id="u1")
|
|
|
|
def test_get_all_rejects_agent_id_kwarg(self, mock_memory_client):
|
|
"""get_all() should reject agent_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"agent_id"):
|
|
mock_memory_client.get_all(agent_id="a1")
|
|
|
|
def test_get_all_rejects_app_id_kwarg(self, mock_memory_client):
|
|
"""get_all() should reject app_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"app_id"):
|
|
mock_memory_client.get_all(app_id="app1")
|
|
|
|
def test_get_all_rejects_run_id_kwarg(self, mock_memory_client):
|
|
"""get_all() should reject run_id as top-level kwarg."""
|
|
with pytest.raises(ValueError, match=r"run_id"):
|
|
mock_memory_client.get_all(run_id="r1")
|
|
|
|
def test_get_all_passes_show_expired(self, mock_memory_client):
|
|
"""get_all() should pass show_expired to the API."""
|
|
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"}, show_expired=True)
|
|
|
|
mock_memory_client.client.post.assert_called_once_with(
|
|
"/v3/memories/",
|
|
json={"filters": {"user_id": "u1"}, "show_expired": True},
|
|
)
|
|
|
|
|
|
class TestUpdateExpirationDate:
|
|
"""Tests for update expiration_date payload handling."""
|
|
|
|
def test_update_preserves_null_expiration_date(self, mock_memory_client):
|
|
"""update() should send expiration_date=None so the API can clear it."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"id": "mem_1", "expiration_date": None}
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_memory_client.client.put.return_value = mock_response
|
|
|
|
mock_memory_client.update("mem_1", expiration_date=None)
|
|
|
|
mock_memory_client.client.put.assert_called_once_with(
|
|
"/v1/memories/mem_1/",
|
|
json={"expiration_date": None},
|
|
params={},
|
|
)
|
|
|
|
|
|
class TestFilterOperatorPassthrough:
|
|
"""Tests that AND/OR/NOT filter operators are passed through to the API."""
|
|
|
|
def test_search_passes_and_filters(self, mock_memory_client):
|
|
"""search() should pass AND filters to the API."""
|
|
mock_memory_client.client.post.return_value = MagicMock(
|
|
json=lambda: {"results": []},
|
|
raise_for_status=lambda: None
|
|
)
|
|
|
|
mock_memory_client.search(
|
|
"test query",
|
|
filters={"AND": [{"user_id": "u1"}, {"created_at": {"gte": "2024-01-01"}}]}
|
|
)
|
|
|
|
# Verify the POST was called with filters intact
|
|
call_args = mock_memory_client.client.post.call_args
|
|
payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {})
|
|
assert payload["filters"] == {"AND": [{"user_id": "u1"}, {"created_at": {"gte": "2024-01-01"}}]}
|
|
|
|
def test_search_passes_or_filters(self, mock_memory_client):
|
|
"""search() should pass OR filters to the API."""
|
|
mock_memory_client.client.post.return_value = MagicMock(
|
|
json=lambda: {"results": []},
|
|
raise_for_status=lambda: None
|
|
)
|
|
|
|
mock_memory_client.search(
|
|
"test query",
|
|
filters={"OR": [{"user_id": "u1"}, {"agent_id": "a1"}]}
|
|
)
|
|
|
|
call_args = mock_memory_client.client.post.call_args
|
|
payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {})
|
|
assert payload["filters"] == {"OR": [{"user_id": "u1"}, {"agent_id": "a1"}]}
|
|
|
|
def test_search_passes_not_filters(self, mock_memory_client):
|
|
"""search() should pass NOT filters to the API."""
|
|
mock_memory_client.client.post.return_value = MagicMock(
|
|
json=lambda: {"results": []},
|
|
raise_for_status=lambda: None
|
|
)
|
|
|
|
mock_memory_client.search(
|
|
"test query",
|
|
filters={"AND": [{"user_id": "u1"}, {"NOT": {"categories": {"in": ["spam"]}}}]}
|
|
)
|
|
|
|
call_args = mock_memory_client.client.post.call_args
|
|
payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {})
|
|
assert payload["filters"] == {"AND": [{"user_id": "u1"}, {"NOT": {"categories": {"in": ["spam"]}}}]}
|
|
|
|
def test_search_passes_complex_nested_filters(self, mock_memory_client):
|
|
"""search() should pass complex nested AND/OR/NOT filters to the API."""
|
|
mock_memory_client.client.post.return_value = MagicMock(
|
|
json=lambda: {"results": []},
|
|
raise_for_status=lambda: None
|
|
)
|
|
|
|
complex_filter = {
|
|
"AND": [
|
|
{"user_id": "u1"},
|
|
{"created_at": {"gte": "2024-01-01"}},
|
|
{"NOT": {"OR": [{"categories": {"in": ["spam"]}}, {"categories": {"in": ["test"]}}]}}
|
|
]
|
|
}
|
|
mock_memory_client.search("test query", filters=complex_filter)
|
|
|
|
call_args = mock_memory_client.client.post.call_args
|
|
payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {})
|
|
assert payload["filters"] == complex_filter
|
|
|
|
|
|
class TestDeleteLinked:
|
|
"""delete() should forward the opt-in delete_linked flag as a query param."""
|
|
|
|
def _setup_delete(self, client):
|
|
client.client.delete.return_value = MagicMock(
|
|
json=lambda: {"message": "Memory deleted successfully!"},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
def test_delete_default_omits_delete_linked(self, mock_memory_client):
|
|
"""Default delete sends no delete_linked param — byte-identical to before."""
|
|
self._setup_delete(mock_memory_client)
|
|
|
|
mock_memory_client.delete("mem_123")
|
|
|
|
call_args = mock_memory_client.client.delete.call_args
|
|
assert call_args.args[0] == "/v1/memories/mem_123/"
|
|
assert "delete_linked" not in call_args.kwargs.get("params", {})
|
|
|
|
def test_delete_linked_true_sets_param(self, mock_memory_client):
|
|
"""delete_linked=True forwards delete_linked into the request params."""
|
|
self._setup_delete(mock_memory_client)
|
|
|
|
mock_memory_client.delete("mem_123", delete_linked=True)
|
|
|
|
call_args = mock_memory_client.client.delete.call_args
|
|
assert call_args.kwargs.get("params", {}).get("delete_linked") is True
|
|
|
|
def test_delete_linked_false_omits_param(self, mock_memory_client):
|
|
"""delete_linked=False is stripped, so the default path is untouched."""
|
|
self._setup_delete(mock_memory_client)
|
|
|
|
mock_memory_client.delete("mem_123", delete_linked=False)
|
|
|
|
call_args = mock_memory_client.client.delete.call_args
|
|
assert "delete_linked" not in call_args.kwargs.get("params", {})
|
|
|
|
|
|
class TestPathSegmentEncoding:
|
|
"""Path params should remain one URL segment even when IDs contain URL syntax."""
|
|
|
|
def _setup_response(self):
|
|
return MagicMock(json=lambda: {"message": "ok"}, raise_for_status=lambda: None)
|
|
|
|
def _called_paths(self, mock_method):
|
|
return [call.args[0] for call in mock_method.call_args_list]
|
|
|
|
def test_sync_memory_id_path_segments_are_encoded(self, mock_memory_client):
|
|
mock_memory_client.client.get.return_value = self._setup_response()
|
|
mock_memory_client.client.put.return_value = self._setup_response()
|
|
mock_memory_client.client.delete.return_value = self._setup_response()
|
|
|
|
memory_id = "mem/a?b#c"
|
|
|
|
mock_memory_client.get(memory_id)
|
|
mock_memory_client.update(memory_id, text="updated")
|
|
mock_memory_client.delete(memory_id)
|
|
mock_memory_client.history(memory_id)
|
|
|
|
assert "/v1/memories/mem%2Fa%3Fb%23c/" in self._called_paths(mock_memory_client.client.get)
|
|
assert mock_memory_client.client.put.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
|
assert mock_memory_client.client.delete.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
|
assert "/v1/memories/mem%2Fa%3Fb%23c/history/" in self._called_paths(mock_memory_client.client.get)
|
|
|
|
def test_sync_entity_path_segments_are_encoded(self, mock_memory_client):
|
|
mock_memory_client.client.delete.return_value = self._setup_response()
|
|
|
|
mock_memory_client.delete_users(user_id="org/team?active#frag")
|
|
|
|
assert mock_memory_client.client.delete.call_args.args[0] == (
|
|
"/v2/entities/user/org%2Fteam%3Factive%23frag/"
|
|
)
|
|
|
|
def test_async_memory_id_path_segments_are_encoded(self):
|
|
asyncio.run(self._assert_async_memory_id_path_segments_are_encoded())
|
|
|
|
async def _assert_async_memory_id_path_segments_are_encoded(self):
|
|
from mem0.client.main import AsyncMemoryClient
|
|
|
|
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
|
client.async_client = MagicMock()
|
|
client.async_client.get = AsyncMock(return_value=self._setup_response())
|
|
client.async_client.put = AsyncMock(return_value=self._setup_response())
|
|
client.async_client.delete = AsyncMock(return_value=self._setup_response())
|
|
|
|
memory_id = "mem/a?b#c"
|
|
|
|
with patch("mem0.client.main.capture_client_event"):
|
|
await client.get(memory_id)
|
|
await client.update(memory_id, text="updated")
|
|
await client.delete(memory_id)
|
|
await client.history(memory_id)
|
|
|
|
assert client.async_client.get.call_args_list[0].args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
|
assert client.async_client.put.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
|
assert client.async_client.delete.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
|
assert client.async_client.get.call_args_list[1].args[0] == "/v1/memories/mem%2Fa%3Fb%23c/history/"
|
|
|
|
def test_async_entity_path_segments_are_encoded(self):
|
|
asyncio.run(self._assert_async_entity_path_segments_are_encoded())
|
|
|
|
async def _assert_async_entity_path_segments_are_encoded(self):
|
|
from mem0.client.main import AsyncMemoryClient
|
|
|
|
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
|
client.async_client = MagicMock()
|
|
client.async_client.delete = AsyncMock(return_value=self._setup_response())
|
|
|
|
with patch("mem0.client.main.capture_client_event"):
|
|
await client.delete_users(user_id="org/team?active#frag")
|
|
|
|
assert client.async_client.delete.call_args.args[0] == (
|
|
"/v2/entities/user/org%2Fteam%3Factive%23frag/"
|
|
)
|
|
|
|
|
|
class TestValidateApiKeyHttpError:
|
|
"""_validate_api_key should surface a clear ValueError on a non-JSON HTTP error.
|
|
|
|
A 5xx from a CDN/proxy often has an HTML body, so response.json() fails. The
|
|
HTTP status must be checked before json() so the intended ValueError is raised
|
|
instead of a confusing JSONDecodeError during client construction.
|
|
"""
|
|
|
|
def test_sync_client_non_json_5xx_raises_clear_error(self):
|
|
# HTML body from a CDN/proxy: json() fails, but raise_for_status() reports the 503.
|
|
request = httpx.Request("GET", "https://api.mem0.ai/v1/ping/")
|
|
error_response = httpx.Response(503, text="<html>503 Service Unavailable</html>", request=request)
|
|
response = MagicMock()
|
|
response.json.side_effect = json.JSONDecodeError("Expecting value", "<html>", 0)
|
|
response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Server error", request=request, response=error_response
|
|
)
|
|
|
|
with patch("mem0.client.main.httpx.Client") as mock_httpx:
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.get.return_value = response
|
|
mock_httpx.return_value = mock_http_client
|
|
|
|
with patch("mem0.client.main.capture_client_event"):
|
|
from mem0.client.main import MemoryClient
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
MemoryClient(api_key="test-api-key")
|
|
|
|
# The HTTP status must surface as the intended "Error: ..." ValueError,
|
|
# not the raw JSONDecodeError from parsing the HTML body.
|
|
assert not isinstance(exc_info.value, json.JSONDecodeError)
|
|
assert "Error:" in str(exc_info.value)
|
|
|
|
def test_async_client_non_json_5xx_raises_clear_error(self):
|
|
request = httpx.Request("GET", "https://api.mem0.ai/v1/ping/")
|
|
error_response = httpx.Response(503, text="<html>503 Service Unavailable</html>", request=request)
|
|
response = MagicMock()
|
|
response.json.side_effect = requests.exceptions.JSONDecodeError("Expecting value", "<html>", 0)
|
|
http_error = requests.exceptions.HTTPError("Server error", response=error_response)
|
|
response.raise_for_status.side_effect = http_error
|
|
|
|
with patch("mem0.client.main.requests.get", return_value=response):
|
|
with patch("mem0.client.main.capture_client_event"):
|
|
from mem0.client.main import AsyncMemoryClient
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
AsyncMemoryClient(api_key="test-api-key")
|
|
|
|
assert not isinstance(exc_info.value, requests.exceptions.JSONDecodeError)
|
|
assert "Error:" in str(exc_info.value)
|