34894e2cea
Four POST routes became one collection with the operation in the body, so the three route pages collapse into one. samples became sample. Creates send an Idempotency-Key and return a status_url to poll, which the client follows rather than building the path. Full rebuild is closed: regenerate_profiles answers 501 not_yet_available and the docs say so instead of teaching a daily cadence that cannot run.
234 lines
9.0 KiB
Python
234 lines
9.0 KiB
Python
"""Tests for the MemoryClient profile methods.
|
|
|
|
These assert request construction — path, verb, body — rather than echoing a
|
|
mocked response back. The profile payload itself is the customer's own JSON
|
|
Schema shape, so the tests also pin that the SDK passes it through untouched.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_memory_client():
|
|
"""A MemoryClient whose transport is mocked."""
|
|
with patch("mem0.client.main.httpx.Client") as mock_httpx:
|
|
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")
|
|
# The constructor pings through this same mock; drop that call.
|
|
mock_http_client.get.reset_mock()
|
|
yield client
|
|
|
|
|
|
def _assert_job_call(post_mock, expected_json):
|
|
"""One jobs collection, operation in the body, idempotency key per attempt."""
|
|
post_mock.assert_called_once()
|
|
args, kwargs = post_mock.call_args
|
|
assert args[0] == "/v2/profiles/jobs/"
|
|
assert kwargs["json"] == expected_json
|
|
assert kwargs["headers"]["Idempotency-Key"]
|
|
|
|
|
|
def _mock_response(payload):
|
|
response = MagicMock()
|
|
response.json.return_value = payload
|
|
response.raise_for_status.return_value = None
|
|
return response
|
|
|
|
|
|
class TestGetProfile:
|
|
def test_reads_the_v2_entity_route(self, mock_memory_client):
|
|
mock_memory_client.client.get.return_value = _mock_response(
|
|
{"profile": {}, "status": "pending", "entity_type": "user", "entity_id": "alice"}
|
|
)
|
|
|
|
mock_memory_client.get_profile("alice")
|
|
|
|
mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
|
|
|
|
def test_supports_agents(self, mock_memory_client):
|
|
mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
|
|
|
|
mock_memory_client.get_profile("support-bot", entity_type="agent")
|
|
|
|
mock_memory_client.client.get.assert_called_once_with("/v2/entities/agent/support-bot/profile/")
|
|
|
|
def test_encodes_path_segments(self, mock_memory_client):
|
|
"""An id with a slash must not open a new path segment."""
|
|
mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
|
|
|
|
mock_memory_client.get_profile("tenant/alice")
|
|
|
|
mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/tenant%2Falice/profile/")
|
|
|
|
def test_returns_the_envelope_verbatim(self, mock_memory_client):
|
|
"""The customer's schema keys reach the caller exactly as stored."""
|
|
payload = {
|
|
"profile": {"favorite_topics": ["hiking"], "work_style": {"preferred_hours": "mornings"}},
|
|
"status": "succeeded",
|
|
"entity_type": "user",
|
|
"entity_id": "alice",
|
|
"updated_at": "2026-02-08T00:00:00Z",
|
|
"generation_count": 3,
|
|
}
|
|
mock_memory_client.client.get.return_value = _mock_response(payload)
|
|
|
|
assert mock_memory_client.get_profile("alice") == payload
|
|
|
|
|
|
class TestGenerateProfile:
|
|
def test_posts_entity_type_and_id(self, mock_memory_client):
|
|
mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_1", "status": "PENDING"})
|
|
|
|
mock_memory_client.generate_profile("alice")
|
|
|
|
_assert_job_call(
|
|
mock_memory_client.client.post,
|
|
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
|
|
)
|
|
|
|
def test_agent_entity_type(self, mock_memory_client):
|
|
mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_2", "status": "PENDING"})
|
|
|
|
mock_memory_client.generate_profile("support-bot", entity_type="agent")
|
|
|
|
_assert_job_call(
|
|
mock_memory_client.client.post,
|
|
{"operation": "trigger", "entity_type": "agent", "entity_id": "support-bot"},
|
|
)
|
|
|
|
|
|
class TestProfileSettings:
|
|
def test_get_reads_v2(self, mock_memory_client):
|
|
mock_memory_client.client.get.return_value = _mock_response(
|
|
{"enabled": True, "schema": None, "custom_instructions": None}
|
|
)
|
|
|
|
mock_memory_client.get_profile_settings()
|
|
|
|
mock_memory_client.client.get.assert_called_once_with("/v2/profiles/settings/")
|
|
|
|
def test_update_sends_only_supplied_fields(self, mock_memory_client):
|
|
"""A partial update must not blank the fields it never mentions."""
|
|
mock_memory_client.client.post.return_value = _mock_response({"enabled": False})
|
|
|
|
mock_memory_client.update_profile_settings(enabled=False)
|
|
|
|
mock_memory_client.client.post.assert_called_once_with(
|
|
"/v2/profiles/settings/",
|
|
json={"enabled": False},
|
|
)
|
|
|
|
def test_update_passes_schema_verbatim(self, mock_memory_client):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"favorite_topics": {
|
|
"type": "array",
|
|
"description": "Topics the user returns to",
|
|
"items": {"type": "string"},
|
|
}
|
|
},
|
|
}
|
|
mock_memory_client.client.post.return_value = _mock_response({"enabled": True, "schema": schema})
|
|
|
|
mock_memory_client.update_profile_settings(enabled=True, schema=schema, custom_instructions="Keep it durable")
|
|
|
|
mock_memory_client.client.post.assert_called_once_with(
|
|
"/v2/profiles/settings/",
|
|
json={"enabled": True, "schema": schema, "custom_instructions": "Keep it durable"},
|
|
)
|
|
|
|
|
|
class TestSampleAndRegenerate:
|
|
def test_sample_without_limit(self, mock_memory_client):
|
|
mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "results": []})
|
|
|
|
mock_memory_client.sample_profiles()
|
|
|
|
_assert_job_call(mock_memory_client.client.post, {"operation": "sample"})
|
|
|
|
def test_sample_with_limit(self, mock_memory_client):
|
|
mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "results": []})
|
|
|
|
mock_memory_client.sample_profiles(limit=3)
|
|
|
|
_assert_job_call(mock_memory_client.client.post, {"operation": "sample", "limit": 3})
|
|
|
|
def test_regenerate(self, mock_memory_client):
|
|
mock_memory_client.client.post.return_value = _mock_response(
|
|
{"status": "accepted", "project_id": "proj_abc", "existing_profile_count": 12}
|
|
)
|
|
|
|
mock_memory_client.regenerate_profiles()
|
|
|
|
_assert_job_call(mock_memory_client.client.post, {"operation": "regenerate"})
|
|
|
|
|
|
class TestAsyncClientParity:
|
|
"""The async client must speak the same wire protocol as the sync one."""
|
|
|
|
@pytest.fixture
|
|
def async_client(self):
|
|
# AsyncMemoryClient validates the key synchronously, through requests.
|
|
validation = MagicMock()
|
|
validation.json.return_value = {
|
|
"org_id": "org1",
|
|
"project_id": "proj1",
|
|
"user_email": "test@test.com",
|
|
}
|
|
validation.raise_for_status.return_value = None
|
|
|
|
with patch("mem0.client.main.httpx.AsyncClient") as mock_httpx:
|
|
mock_httpx.return_value = MagicMock()
|
|
with patch("mem0.client.main.requests.get", return_value=validation):
|
|
with patch("mem0.client.main.capture_client_event"):
|
|
from mem0.client.main import AsyncMemoryClient
|
|
|
|
yield AsyncMemoryClient(api_key="test-api-key")
|
|
|
|
def test_get_profile(self, async_client):
|
|
async_client.async_client.get = AsyncMock(return_value=_mock_response({"profile": {}, "status": "pending"}))
|
|
|
|
asyncio.run(async_client.get_profile("alice"))
|
|
|
|
async_client.async_client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
|
|
|
|
def test_generate_profile(self, async_client):
|
|
async_client.async_client.post = AsyncMock(return_value=_mock_response({"profile_id": "p_1"}))
|
|
|
|
asyncio.run(async_client.generate_profile("alice", entity_type="agent"))
|
|
|
|
_assert_job_call(
|
|
async_client.async_client.post,
|
|
{"operation": "trigger", "entity_type": "agent", "entity_id": "alice"},
|
|
)
|
|
|
|
def test_update_settings_partial(self, async_client):
|
|
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
|
|
|
|
asyncio.run(async_client.update_profile_settings(enabled=True))
|
|
|
|
async_client.async_client.post.assert_called_once_with(
|
|
"/v2/profiles/settings/",
|
|
json={"enabled": True},
|
|
)
|
|
|
|
def test_regenerate(self, async_client):
|
|
async_client.async_client.post = AsyncMock(return_value=_mock_response({"status": "accepted"}))
|
|
|
|
asyncio.run(async_client.regenerate_profiles())
|
|
|
|
_assert_job_call(async_client.async_client.post, {"operation": "regenerate"})
|