Files
mem0/tests/test_client_profiles.py
T
Pratik 949b026991 fix(profiles): correct the settings shape, job entity_type and status type
Four bugs that made the profile SDK unusable against the live API, each
found by running the demo notebook end to end rather than by reading.

1. `update_profile_settings()` sent a flat body:

       {"enabled": ..., "schema": ..., "custom_instructions": ...}

   The API takes only `enabled` and `entities` at the top level and
   answers 400 "Unsupported settings: custom_instructions, schema." to
   anything else, so every call passing a schema failed.
   `get_profile_settings()` already returned the nested shape, so the read
   and the write disagreed and the method could not round-trip its own
   settings. Both SDKs now nest `schema` and `custom_instructions` under
   `entities.<entity_type>` while keeping the flat call signature;
   `entity_type` is a new optional argument defaulting to "user".

2. `sample_profiles()` and `regenerate_profiles()` never sent
   `entity_type`. Every profile job must name an entity kind, so both
   failed with "entity_type must be one of: user, agent."

3. The TypeScript read path rewrote the customer's schema property names.
   Once the schema moved under `entities`, `snakeToCamelKeys` camel-cased
   the keys inside it, because only a top-level schema was restored
   verbatim. A field named `favorite_topics` came back as `favoriteTopics`.

4. `ProfileStatus` declared `notEnabled` and `insufficientData`, but a
   status is a value, not a key, so it is never camel-cased. tsc rejected
   `status === "insufficient_data"`, which is true at runtime, and accepted
   `status === "insufficientData"`, which can never fire. Branching on
   status is the documented way to use a profile, so the type steered
   every TypeScript caller into a dead branch.

Response-shape corrections found alongside them: sample returns
`entity_ids` on create and a richer `results` array on the job, so the TS
`results` field on the create response is marked deprecated and never set;
regenerate answers 409, not the 501 the docstrings claimed.

Verified against a live environment, from both SDKs: a settings write
followed by a read returns the schema property for property, a partial
update no longer blanks it, sample returns 202 with the entities it
picked, regenerate reaches the server and answers its real
not_yet_available, and a user with no profile returns "insufficient_data".

Adds a user-profiles demo notebook covering the whole loop: schema,
ingestion, generation, a before/after diff of a profile rewriting itself,
schema sampling, and a failure-scenario section for each way the API says
no. It polls the add event to a terminal status instead of sleeping, waits
for the extracted memory count to settle rather than trusting the first
page, and reports plainly when generation cannot finish instead of
presenting an empty profile as a result. Executed end to end: 0 failing
cells.

- python: 20 passed (tests/test_client_profiles.py)
- typescript: 203 passed (src/client/tests/)
2026-09-18 17:10:31 -07:00

284 lines
11 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, "entities": {"user": {"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_nests_schema_under_entities(self, mock_memory_client):
"""The API takes only ``enabled`` and ``entities`` at the top level.
A flat body is rejected with ``Unsupported settings``, so this nesting is
what makes the call work at all.
"""
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
mock_memory_client.update_profile_settings(enabled=True, schema=schema)
_, kwargs = mock_memory_client.client.post.call_args
assert set(kwargs["json"]) == {"enabled", "entities"}
assert "schema" not in kwargs["json"]
def test_update_targets_the_named_entity_type(self, mock_memory_client):
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
mock_memory_client.update_profile_settings(schema=schema, entity_type="agent")
_, kwargs = mock_memory_client.client.post.call_args
assert kwargs["json"] == {"entities": {"agent": {"schema": schema}}}
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})
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,
"entities": {"user": {"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, "entity_ids": []})
mock_memory_client.sample_profiles()
_assert_job_call(mock_memory_client.client.post, {"operation": "sample", "entity_type": "user"})
def test_sample_with_limit(self, mock_memory_client):
mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "entity_ids": []})
mock_memory_client.sample_profiles(limit=3)
_assert_job_call(
mock_memory_client.client.post,
{"operation": "sample", "limit": 3, "entity_type": "user"},
)
def test_sample_names_the_entity_type(self, mock_memory_client):
"""Every job names an entity kind: the API refuses one that does not."""
mock_memory_client.client.post.return_value = _mock_response({"sampled": 1, "entity_ids": []})
mock_memory_client.sample_profiles(entity_type="agent")
_assert_job_call(mock_memory_client.client.post, {"operation": "sample", "entity_type": "agent"})
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", "entity_type": "user"})
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_update_settings_nests_schema(self, async_client):
"""The async client builds the same body as the sync one."""
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
asyncio.run(async_client.update_profile_settings(enabled=True, schema=schema))
async_client.async_client.post.assert_called_once_with(
"/v2/profiles/settings/",
json={"enabled": True, "entities": {"user": {"schema": schema}}},
)
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", "entity_type": "user"})