refactor: fix webhook create/update serialization, add payload types, and MEMORY_CATEGORIZED event (#4429)

Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
This commit is contained in:
Kartik
2026-03-19 22:26:17 +05:30
committed by GitHub
co-authored by Saket Aryan
parent 88abb29de9
commit 6663b738d5
11 changed files with 362 additions and 128 deletions
@@ -177,23 +177,6 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
});
});
// ─── Delete single ────────────────────────────────────────
describe("delete memory", () => {
test("deletes a single memory by ID", async () => {
const memoryId = memoryIds[0];
expect(memoryId).toBeDefined();
const result = await client.delete(memoryId);
expect(result).toBeDefined();
expect(typeof result.message).toBe("string");
});
test("getting deleted memory throws MemoryError", async () => {
const memoryId = memoryIds[0];
await expect(client.get(memoryId)).rejects.toThrow(MemoryError);
});
});
// ─── Edge cases ──────────────────────────────────────────
describe("edge cases", () => {
test("add with metadata attaches metadata to the memory", async () => {
@@ -234,6 +217,25 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
});
});
// ─── Delete single ────────────────────────────────────────
// NOTE: Delete tests run last to avoid race conditions with
// other tests that depend on the seeded memories.
describe("delete memory", () => {
test("deletes a single memory by ID", async () => {
const memoryId = memoryIds[0];
expect(memoryId).toBeDefined();
const result = await client.delete(memoryId);
expect(result).toBeDefined();
expect(typeof result.message).toBe("string");
});
test("getting deleted memory throws MemoryError", async () => {
const memoryId = memoryIds[0];
await expect(client.get(memoryId)).rejects.toThrow(MemoryError);
});
});
// ─── Delete all + delete user ─────────────────────────────
describe("cleanup operations", () => {
test("deletes all memories for test user", async () => {
+23 -10
View File
@@ -51,43 +51,56 @@ export async function withRetry<T>(
* Poll getAll until memories appear for a user.
* The Mem0 API processes memories asynchronously — after add()
* we need to wait for them to be available.
*
* Polls every 15 seconds with a maximum of 4 retries to avoid
* hitting rate limits. Throws if results aren't available after
* all retries.
*/
export async function waitForMemories(
client: MemoryClient,
userId: string,
minCount: number,
maxWaitMs = 60_000,
maxRetries = 4,
): Promise<Memory[]> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const memories = await withRetry(() => client.getAll({ user_id: userId }));
if (Array.isArray(memories) && memories.length >= minCount) {
return memories;
}
await new Promise((r) => setTimeout(r, 3_000));
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 15_000));
}
}
return await client.getAll({ user_id: userId });
throw new Error(
`waitForMemories: expected at least ${minCount} memories for user "${userId}" but did not get them after ${maxRetries} attempts`,
);
}
/**
* Poll search until results appear. Only used by search tests —
* other test files should NOT call this to avoid wasting API credits.
*
* Polls every 15 seconds with a maximum of 4 retries. Throws if
* no results are found after all retries.
*/
export async function waitForSearchResults(
client: MemoryClient,
query: string,
options: Record<string, any>,
maxWaitMs = 60_000,
maxRetries = 4,
): Promise<Memory[]> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const results = await withRetry(() => client.search(query, options));
if (Array.isArray(results) && results.length > 0) {
return results;
}
await new Promise((r) => setTimeout(r, 3_000));
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 15_000));
}
}
return await client.search(query, options);
throw new Error(
`waitForSearchResults: no results for query "${query}" after ${maxRetries} attempts`,
);
}
/**
@@ -1,15 +1,12 @@
/**
* Integration tests: User management and project configuration.
* Integration tests: User management, project configuration, and webhooks.
*
* Tests users(), getProject(), and updateProject() against the real API.
*
* Note: Webhook tests (createWebhook, updateWebhook) are excluded because
* the SDK has a known bug where it sends camelCase keys (eventTypes) instead
* of snake_case (event_types). These will be added once the SDK is fixed.
* Tests users(), getProject(), updateProject(), and webhook CRUD against the real API.
*
* Run: MEM0_API_KEY=your-key npx jest management.test.ts --forceExit
*/
import { MemoryClient } from "../../mem0";
import { WebhookEvent } from "../../mem0.types";
import { randomUUID } from "crypto";
import {
describeIntegration,
@@ -17,6 +14,7 @@ import {
suppressTelemetryNoise,
seedTestMemories,
cleanupTestUser,
withRetry,
} from "./helpers";
jest.setTimeout(120_000);
@@ -95,4 +93,115 @@ describeIntegration("MemoryClient Integration — Users & Project", () => {
});
});
});
// ─── Webhooks ──────────────────────────────────────────────
describe("webhook management", () => {
let createdWebhookId: string;
const hookName = `test-hook-${randomUUID().slice(0, 8)}`;
const hookUrl = `https://example.com/webhook/${randomUUID().slice(0, 8)}`;
const updatedName = `updated-hook-${randomUUID().slice(0, 8)}`;
afterAll(async () => {
if (createdWebhookId) {
try {
await client.deleteWebhook({ webhookId: createdWebhookId });
} catch {
// ignore — may already be deleted
}
}
});
// ─── Create ────────────────────────────────────────────
test("createWebhook returns a webhook_id", async () => {
const result = await withRetry(() =>
client.createWebhook({
name: hookName,
url: hookUrl,
eventTypes: [WebhookEvent.MEMORY_ADDED, WebhookEvent.MEMORY_UPDATED],
}),
);
createdWebhookId = result.webhook_id!;
expect(result.webhook_id).toBeDefined();
});
test("createWebhook returns the correct name", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const wh = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(wh!.name).toBe(hookName);
});
test("createWebhook returns the correct url", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const wh = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(wh!.url).toBe(hookUrl);
});
test("createWebhook returns the correct event_types", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const wh = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(wh!.event_types?.sort()).toStrictEqual(
[WebhookEvent.MEMORY_ADDED, WebhookEvent.MEMORY_UPDATED].sort(),
);
});
// ─── List ──────────────────────────────────────────────
test("getWebhooks returns an array", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
expect(Array.isArray(webhooks)).toBe(true);
});
test("getWebhooks includes the created webhook", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const found = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(found).toBeDefined();
});
test("getWebhooks shows the webhook as active", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const found = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(found!.is_active).toBe(true);
});
// ─── Update ────────────────────────────────────────────
test("updateWebhook returns a success message", async () => {
const result = await withRetry(() =>
client.updateWebhook({
webhookId: createdWebhookId,
name: updatedName,
url: "https://example.com/updated",
eventTypes: [WebhookEvent.MEMORY_DELETED],
}),
);
expect(result.message).toBeDefined();
});
test("updateWebhook persists the new name", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const updated = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(updated!.name).toBe(updatedName);
});
test("updateWebhook persists the new event_types", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const updated = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(updated!.event_types?.sort()).toStrictEqual(
[WebhookEvent.MEMORY_DELETED].sort(),
);
});
// ─── Delete ────────────────────────────────────────────
test("deleteWebhook returns a response", async () => {
const result = await withRetry(() =>
client.deleteWebhook({ webhookId: createdWebhookId }),
);
expect(result).toBeDefined();
});
test("deleteWebhook removes the webhook from the list", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const found = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(found).toBeUndefined();
createdWebhookId = "";
});
});
});
@@ -1,6 +1,7 @@
/**
* MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook.
* Tests verify request URL and HTTP method, not mock response values.
* Tests verify request URL, HTTP method, and payload serialization.
* One expect per test case.
*/
import { MemoryClient } from "../mem0";
import { WebhookEvent } from "../mem0.types";
@@ -14,17 +15,26 @@ import {
installConsoleSuppression();
describe("MemoryClient - Webhooks", () => {
test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => {
// ─── Helpers ──────────────────────────────────────────────
function webhookMock(extra?: Map<string, { status: number; body: unknown }>) {
return setupMockFetch(extra);
}
function createClient() {
return new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
}
// ─── getWebhooks ──────────────────────────────────────────
describe("MemoryClient - getWebhooks", () => {
test("sends GET to /api/v1/webhooks/projects/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
const mock = webhookMock(extra);
const client = createClient();
await client.getWebhooks();
const call = mock.mock.calls.find(
@@ -33,117 +43,170 @@ describe("MemoryClient - Webhooks", () => {
);
expect(call).toBeDefined();
});
});
test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => {
// ─── createWebhook ────────────────────────────────────────
describe("MemoryClient - createWebhook", () => {
async function callCreate() {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_new" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
const mock = webhookMock(extra);
const client = createClient();
await client.createWebhook({
name: "new-hook",
url: "https://example.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
webhookId: "",
});
return mock;
}
test("sends POST to /api/v1/webhooks/projects/:id/", async () => {
const mock = await callCreate();
expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined();
});
test("createWebhook includes webhook payload in body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_new" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.createWebhook({
name: "new-hook",
url: "https://example.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
webhookId: "",
});
const call = findFetchCall(mock, "/api/v1/webhooks/", "POST");
const body = getFetchBody(call!);
test("body contains name", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.name).toBe("new-hook");
});
test("body contains url", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.url).toBe("https://example.com");
});
test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => {
test("body contains event_types in snake_case", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.event_types).toStrictEqual([WebhookEvent.MEMORY_ADDED]);
});
test("body does not contain camelCase eventTypes", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.eventTypes).toBeUndefined();
});
test("body does not contain projectId", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.projectId).toBeUndefined();
});
test("body does not contain webhookId", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.webhookId).toBeUndefined();
});
});
// ─── updateWebhook ────────────────────────────────────────
describe("MemoryClient - updateWebhook", () => {
async function callUpdate() {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
const mock = webhookMock(extra);
const client = createClient();
await client.updateWebhook({
webhookId: "wh_1",
name: "updated-hook",
url: "https://new-url.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
});
return mock;
}
test("sends PUT to /api/v1/webhooks/:id/", async () => {
const mock = await callUpdate();
expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined();
});
test("updateWebhook includes updated fields in body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.updateWebhook({
webhookId: "wh_1",
name: "updated-hook",
url: "https://new-url.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
});
const call = findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT");
const body = getFetchBody(call!);
test("body contains name", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.name).toBe("updated-hook");
});
test("body contains url", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.url).toBe("https://new-url.com");
});
test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => {
test("body contains event_types in snake_case", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.event_types).toStrictEqual([WebhookEvent.MEMORY_ADDED]);
});
test("body does not contain camelCase eventTypes", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.eventTypes).toBeUndefined();
});
test("body does not contain project_id", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.project_id).toBeUndefined();
});
test("body does not contain projectId", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.projectId).toBeUndefined();
});
test("body does not contain webhookId", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.webhookId).toBeUndefined();
});
});
// ─── deleteWebhook ────────────────────────────────────────
describe("MemoryClient - deleteWebhook", () => {
test("sends DELETE to /api/v1/webhooks/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook deleted" },
});
const mock = setupMockFetch(extra);
const mock = webhookMock(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.deleteWebhook({ webhookId: "wh_1" });