diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 49906b8bd..0819bf3cb 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -17,6 +17,10 @@ import { type SearchOptions, } from "./base.js"; +function encodePathSegment(value: unknown): string { + return encodeURIComponent(String(value)); +} + export class PlatformBackend implements Backend { private baseUrl: string; private headers: Record; @@ -218,9 +222,13 @@ export class PlatformBackend implements Backend { } async get(memoryId: string): Promise> { - return (await this._request("GET", `/v1/memories/${memoryId}/`, { - params: { source: "CLI" }, - })) as Record; + return (await this._request( + "GET", + `/v1/memories/${encodePathSegment(memoryId)}/`, + { + params: { source: "CLI" }, + }, + )) as Record; } async listMemories( @@ -277,9 +285,13 @@ export class PlatformBackend implements Backend { if (content) payload.text = content; if (metadata) payload.metadata = metadata; payload.source = "CLI"; - return (await this._request("PUT", `/v1/memories/${memoryId}/`, { - json: payload, - })) as Record; + return (await this._request( + "PUT", + `/v1/memories/${encodePathSegment(memoryId)}/`, + { + json: payload, + }, + )) as Record; } async delete( @@ -297,9 +309,13 @@ export class PlatformBackend implements Backend { })) as Record; } if (memoryId) { - return (await this._request("DELETE", `/v1/memories/${memoryId}/`, { - params: { source: "CLI" }, - })) as Record; + return (await this._request( + "DELETE", + `/v1/memories/${encodePathSegment(memoryId)}/`, + { + params: { source: "CLI" }, + }, + )) as Record; } throw new Error("Either memoryId or --all is required"); } @@ -323,7 +339,7 @@ export class PlatformBackend implements Backend { for (const [entityType, entityId] of entities) { results[entityType] = (await this._request( "DELETE", - `/v2/entities/${entityType}/${entityId}/`, + `/v2/entities/${encodePathSegment(entityType)}/${encodePathSegment(entityId)}/`, { params: { source: "CLI" } }, )) as Record; } @@ -386,9 +402,9 @@ export class PlatformBackend implements Backend { } async getEvent(eventId: string): Promise> { - return (await this._request("GET", `/v1/event/${eventId}/`)) as Record< - string, - unknown - >; + return (await this._request( + "GET", + `/v1/event/${encodePathSegment(eventId)}/`, + )) as Record; } } diff --git a/cli/node/tests/platform-backend.test.ts b/cli/node/tests/platform-backend.test.ts index 1857f52bf..cb577a638 100644 --- a/cli/node/tests/platform-backend.test.ts +++ b/cli/node/tests/platform-backend.test.ts @@ -2,7 +2,7 @@ * Tests for the Platform backend (mem0 Platform API client). */ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { PlatformBackend } from "../src/backend/platform.js"; import { createDefaultConfig } from "../src/config.js"; @@ -12,6 +12,22 @@ function makeBackend(): PlatformBackend { return new PlatformBackend(createDefaultConfig().platform); } +function mockFetch() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue(null) }, + json: vi.fn().mockResolvedValue({ message: "ok" }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + describe("deleteEntities", () => { it("returns all results keyed by entity type for a multi-entity delete", async () => { const backend = makeBackend(); @@ -50,3 +66,35 @@ describe("deleteEntities", () => { ); }); }); + +describe("PlatformBackend path encoding", () => { + it("encodes memory IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); + + await backend.get("mem/a?b#c"); + await backend.update("mem/a?b#c", "updated"); + await backend.delete("mem/a?b#c"); + + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + ]); + }); + + it("encodes entity and event IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); + + await backend.deleteEntities({ userId: "org/team?active#frag" }); + await backend.getEvent("evt/a?b#c"); + + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", + "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", + ]); + }); +}); diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index a0e5badd9..1df3deadd 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from urllib.parse import quote import httpx @@ -11,6 +12,10 @@ from mem0_cli.backend.base import Backend from mem0_cli.config import PlatformConfig +def _encode_path_segment(value: Any) -> str: + return quote(str(value), safe="") + + class PlatformBackend(Backend): """Backend that talks to the mem0 Platform API.""" @@ -196,7 +201,11 @@ class PlatformBackend(Backend): ) def get(self, memory_id: str) -> dict: - return self._request("GET", f"/v1/memories/{memory_id}/", params={"source": "CLI"}) + return self._request( + "GET", + f"/v1/memories/{_encode_path_segment(memory_id)}/", + params={"source": "CLI"}, + ) def list_memories( self, @@ -250,7 +259,11 @@ class PlatformBackend(Backend): if metadata: payload["metadata"] = metadata payload["source"] = "CLI" - return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload) + return self._request( + "PUT", + f"/v1/memories/{_encode_path_segment(memory_id)}/", + json=payload, + ) def delete( self, @@ -274,7 +287,11 @@ class PlatformBackend(Backend): params["run_id"] = run_id return self._request("DELETE", "/v1/memories/", params=params) elif memory_id: - return self._request("DELETE", f"/v1/memories/{memory_id}/", params={"source": "CLI"}) + return self._request( + "DELETE", + f"/v1/memories/{_encode_path_segment(memory_id)}/", + params={"source": "CLI"}, + ) else: raise ValueError("Either memory_id or --all is required") @@ -302,7 +319,9 @@ class PlatformBackend(Backend): results: dict = {} for entity_type, entity_id in entities.items(): results[entity_type] = self._request( - "DELETE", f"/v2/entities/{entity_type}/{entity_id}/", params={"source": "CLI"} + "DELETE", + f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/", + params={"source": "CLI"}, ) return results @@ -348,7 +367,7 @@ class PlatformBackend(Backend): return result if isinstance(result, list) else result.get("results", []) def get_event(self, event_id: str) -> dict: - return self._request("GET", f"/v1/event/{event_id}/") + return self._request("GET", f"/v1/event/{_encode_path_segment(event_id)}/") class AuthError(Exception): diff --git a/cli/python/tests/test_platform_backend_paths.py b/cli/python/tests/test_platform_backend_paths.py new file mode 100644 index 000000000..5571dafa8 --- /dev/null +++ b/cli/python/tests/test_platform_backend_paths.py @@ -0,0 +1,43 @@ +from unittest.mock import MagicMock + +from mem0_cli.backend.platform import PlatformBackend + + +def _backend(sample_config): + backend = PlatformBackend(sample_config.platform) + backend._client = MagicMock() + backend._client.request.return_value = MagicMock( + status_code=200, + json=lambda: {"message": "ok"}, + headers={}, + raise_for_status=lambda: None, + ) + return backend + + +def test_memory_id_path_segments_are_encoded(sample_config): + backend = _backend(sample_config) + + backend.get("mem/a?b#c") + backend.update("mem/a?b#c", content="updated") + backend.delete("mem/a?b#c") + + paths = [call.args[1] for call in backend._client.request.call_args_list] + assert paths == [ + "/v1/memories/mem%2Fa%3Fb%23c/", + "/v1/memories/mem%2Fa%3Fb%23c/", + "/v1/memories/mem%2Fa%3Fb%23c/", + ] + + +def test_entity_and_event_path_segments_are_encoded(sample_config): + backend = _backend(sample_config) + + backend.delete_entities(user_id="org/team?active#frag") + backend.get_event("evt/a?b#c") + + paths = [call.args[1] for call in backend._client.request.call_args_list] + assert paths == [ + "/v2/entities/user/org%2Fteam%3Factive%23frag/", + "/v1/event/evt%2Fa%3Fb%23c/", + ] diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 6d5f4c7e1..3fd19eb39 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -67,6 +67,10 @@ function rejectTopLevelEntityParams( } } +function encodePathSegment(value: unknown): string { + return encodeURIComponent(String(value)); +} + class APIError extends Error { constructor(message: string) { super(message); @@ -311,7 +315,7 @@ export default class MemoryClient { this._captureEvent("update", [payloadKeys]); const response = await this._fetchWithErrorHandling( - `${this.host}/v1/memories/${memoryId}/`, + `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`, { method: "PUT", headers: this.headers, @@ -325,7 +329,7 @@ export default class MemoryClient { if (this.telemetryId === "") await this.ping(); this._captureEvent("get", []); return this._fetchWithErrorHandling( - `${this.host}/v1/memories/${memoryId}/`, + `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`, { headers: this.headers, }, @@ -397,7 +401,7 @@ export default class MemoryClient { // @ts-ignore const query = new URLSearchParams(snakeOptions).toString(); return this._fetchWithErrorHandling( - `${this.host}/v1/memories/${memoryId}/${query ? `?${query}` : ""}`, + `${this.host}/v1/memories/${encodePathSegment(memoryId)}/${query ? `?${query}` : ""}`, { method: "DELETE", headers: this.headers, @@ -428,7 +432,7 @@ export default class MemoryClient { if (this.telemetryId === "") await this.ping(); this._captureEvent("history", []); const response = await this._fetchWithErrorHandling( - `${this.host}/v1/memories/${memoryId}/history/`, + `${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`, { headers: this.headers, }, @@ -466,7 +470,7 @@ export default class MemoryClient { data.entity_type = "user"; } const response = await this._fetchWithErrorHandling( - `${this.host}/v1/entities/${data.entity_type}/${data.entity_id}/`, + `${this.host}/v1/entities/${encodePathSegment(data.entity_type)}/${encodePathSegment(data.entity_id)}/`, { method: "DELETE", headers: this.headers, @@ -510,7 +514,9 @@ export default class MemoryClient { for (const entity of to_delete) { try { - await this.client.delete(`/v2/entities/${entity.type}/${entity.name}/`); + await this.client.delete( + `/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`, + ); } catch (error: any) { throw new APIError( `Failed to delete ${entity.type} ${entity.name}: ${error.message}`, diff --git a/mem0-ts/src/client/tests/memoryClient.crud.test.ts b/mem0-ts/src/client/tests/memoryClient.crud.test.ts index db2b20f65..f116955f1 100644 --- a/mem0-ts/src/client/tests/memoryClient.crud.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.crud.test.ts @@ -118,6 +118,20 @@ describe("MemoryClient - get()", () => { const client = new MemoryClient({ apiKey: TEST_API_KEY }); await expect(client.get("nonexistent")).rejects.toThrow("Memory not found"); }); + + test("URL-encodes memory ID path segments", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem%2Fa%3Fb%23c/", { + status: 200, + body: createMockMemory({ id: "mem/a?b#c" }), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.get("mem/a?b#c"); + + expect(findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/")).toBeDefined(); + }); }); // ─── update() ──────────────────────────────────────────── @@ -213,6 +227,22 @@ describe("MemoryClient - update()", () => { "At least one of text, metadata, timestamp, or expirationDate must be provided", ); }); + + test("URL-encodes memory ID path segments", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem%2Fa%3Fb%23c/", { + status: 200, + body: createMockMemory({ id: "mem/a?b#c" }), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.update("mem/a?b#c", { text: "Updated text" }); + + expect( + findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/", "PUT"), + ).toBeDefined(); + }); }); // ─── delete() ──────────────────────────────────────────── @@ -250,6 +280,22 @@ describe("MemoryClient - delete()", () => { expect(call).toBeDefined(); expect(call![0]).toContain("delete_linked=true"); }); + + test("URL-encodes memory ID path segments before adding query params", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem%2Fa%3Fb%23c/", { + status: 200, + body: { message: "Memory deleted successfully" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.delete("mem/a?b#c", { deleteLinked: true }); + + const call = findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/", "DELETE"); + expect(call).toBeDefined(); + expect(call![0]).toContain("delete_linked=true"); + }); }); // ─── deleteAll() ───────────────────────────────────────── @@ -326,4 +372,23 @@ describe("MemoryClient - history()", () => { const result: MemoryHistory[] = await client.history("mem_123"); expect(result).toEqual([]); }); + + test("URL-encodes memory ID path segments", async () => { + const historyEntries = [ + createMockMemoryHistory({ memory_id: "mem/a?b#c" }), + ]; + const extra = new Map(); + extra.set("/v1/memories/mem%2Fa%3Fb%23c/history/", { + status: 200, + body: historyEntries, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.history("mem/a?b#c"); + + expect( + findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/history/"), + ).toBeDefined(); + }); }); diff --git a/mem0-ts/src/client/tests/memoryClient.users.test.ts b/mem0-ts/src/client/tests/memoryClient.users.test.ts index ba232f21c..5cdb30294 100644 --- a/mem0-ts/src/client/tests/memoryClient.users.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.users.test.ts @@ -71,4 +71,43 @@ describe("MemoryClient - deleteUser() (deprecated)", () => { findFetchCall(mock, "/v1/entities/user/456/", "DELETE"), ).toBeDefined(); }); + + test("URL-encodes entity path segments", async () => { + const extra = new Map(); + extra.set("/v1/entities/team%2Ftype/org%2Fteam%3Factive%23frag/", { + status: 200, + body: { message: "Entity deleted successfully!" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.deleteUser({ + entity_id: "org/team?active#frag" as never, + entity_type: "team/type", + }); + + expect( + findFetchCall( + mock, + "/v1/entities/team%2Ftype/org%2Fteam%3Factive%23frag/", + "DELETE", + ), + ).toBeDefined(); + }); +}); + +describe("MemoryClient - deleteUsers()", () => { + test("URL-encodes entity path segments", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + client.telemetryId = "test@example.com"; + const deleteMock = jest.fn().mockResolvedValue({}); + client.client = { delete: deleteMock }; + + await client.deleteUsers({ userId: "org/team?active#frag" }); + + expect(deleteMock).toHaveBeenCalledWith( + "/v2/entities/user/org%2Fteam%3Factive%23frag/", + ); + }); }); diff --git a/mem0/client/main.py b/mem0/client/main.py index c26aa7231..853a8588d 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -3,6 +3,7 @@ import logging import os import warnings from typing import Any, Dict, List, Optional +from urllib.parse import quote import httpx import requests @@ -42,6 +43,10 @@ def _validate_and_trim_search_query(query: str) -> str: return trimmed +def _encode_path_segment(value: Any) -> str: + return quote(str(value), safe="") + + def _maybe_alias_anon_to_email(user_email): """Fire $identify per prior anon ID so PostHog merges them into email. @@ -229,7 +234,7 @@ class MemoryClient: MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ params = self._prepare_params() - response = self.client.get(f"/v1/memories/{memory_id}/", params=params) + response = self.client.get(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params) response.raise_for_status() capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "sync"}) return response.json() @@ -367,7 +372,7 @@ class MemoryClient: capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"}) params = self._prepare_params() - response = self.client.put(f"/v1/memories/{memory_id}/", json=payload, params=params) + response = self.client.put(f"/v1/memories/{_encode_path_segment(memory_id)}/", json=payload, params=params) response.raise_for_status() return response.json() @@ -395,7 +400,7 @@ class MemoryClient: MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ params = self._prepare_params({"delete_linked": delete_linked or None}) - response = self.client.delete(f"/v1/memories/{memory_id}/", params=params) + response = self.client.delete(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params) response.raise_for_status() capture_client_event( "client.delete", self, {"memory_id": memory_id, "delete_linked": delete_linked, "sync_type": "sync"} @@ -452,7 +457,7 @@ class MemoryClient: MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ params = self._prepare_params() - response = self.client.get(f"/v1/memories/{memory_id}/history/", params=params) + response = self.client.get(f"/v1/memories/{_encode_path_segment(memory_id)}/history/", params=params) response.raise_for_status() capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "sync"}) return response.json() @@ -513,7 +518,10 @@ class MemoryClient: # Delete entities and check response immediately for entity in to_delete: - response = self.client.delete(f"/v2/entities/{entity['type']}/{entity['name']}/", params=params) + response = self.client.delete( + f"/v2/entities/{_encode_path_segment(entity['type'])}/{_encode_path_segment(entity['name'])}/", + params=params, + ) response.raise_for_status() capture_client_event( @@ -691,7 +699,9 @@ class MemoryClient: ValueError: If org_id or project_id are not set. """ logger.warning( - "get_project() method is going to be deprecated in version v1.0 of the package. Please use the client.project.get() method instead." + "get_project() method is going to be deprecated in version v1.0 " + "of the package. Please use the client.project.get() method " + "instead." ) if not (self.org_id and self.project_id): raise ValueError("org_id and project_id must be set to access instructions or categories") @@ -1147,7 +1157,7 @@ class AsyncMemoryClient: @api_error_handler async def get(self, memory_id: str) -> Dict[str, Any]: params = self._prepare_params() - response = await self.async_client.get(f"/v1/memories/{memory_id}/", params=params) + response = await self.async_client.get(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params) response.raise_for_status() capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "async"}) return response.json() @@ -1285,7 +1295,9 @@ class AsyncMemoryClient: capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"}) params = self._prepare_params() - response = await self.async_client.put(f"/v1/memories/{memory_id}/", json=payload, params=params) + response = await self.async_client.put( + f"/v1/memories/{_encode_path_segment(memory_id)}/", json=payload, params=params + ) response.raise_for_status() return response.json() @@ -1313,7 +1325,7 @@ class AsyncMemoryClient: MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ params = self._prepare_params({"delete_linked": delete_linked or None}) - response = await self.async_client.delete(f"/v1/memories/{memory_id}/", params=params) + response = await self.async_client.delete(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params) response.raise_for_status() capture_client_event( "client.delete", self, {"memory_id": memory_id, "delete_linked": delete_linked, "sync_type": "async"} @@ -1365,7 +1377,10 @@ class AsyncMemoryClient: MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ params = self._prepare_params() - response = await self.async_client.get(f"/v1/memories/{memory_id}/history/", params=params) + response = await self.async_client.get( + f"/v1/memories/{_encode_path_segment(memory_id)}/history/", + params=params, + ) response.raise_for_status() capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "async"}) return response.json() @@ -1426,7 +1441,10 @@ class AsyncMemoryClient: # Delete entities and check response immediately for entity in to_delete: - response = await self.async_client.delete(f"/v2/entities/{entity['type']}/{entity['name']}/", params=params) + response = await self.async_client.delete( + f"/v2/entities/{_encode_path_segment(entity['type'])}/{_encode_path_segment(entity['name'])}/", + params=params, + ) response.raise_for_status() capture_client_event( @@ -1590,7 +1608,9 @@ class AsyncMemoryClient: ValueError: If org_id or project_id are not set. """ logger.warning( - "get_project() method is going to be deprecated in version v1.0 of the package. Please use the client.project.get() method instead." + "get_project() method is going to be deprecated in version v1.0 " + "of the package. Please use the client.project.get() method " + "instead." ) if not (self.org_id and self.project_id): raise ValueError("org_id and project_id must be set to access instructions or categories") diff --git a/tests/test_client.py b/tests/test_client.py index 2c5934575..8aa554763 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,8 @@ """Tests for MemoryClient entity parameter rejection.""" +import asyncio import json -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest @@ -259,6 +260,84 @@ class TestDeleteLinked: 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.