From 1b7c904b98015e845e61fcc045120c4a678b32dc Mon Sep 17 00:00:00 2001 From: kartik-mem0 Date: Thu, 19 Mar 2026 15:48:35 +0530 Subject: [PATCH] test: add integration tests for CRUD, feedback, export and update CI workflow --- .github/workflows/ts-sdk-ci.yml | 8 +- mem0-ts/jest.integration.config.js | 9 ++ mem0-ts/package.json | 1 + .../src/client/tests/integration/crud.test.ts | 61 ++++++++ .../integration/feedback-and-export.test.ts | 96 ------------ .../client/tests/integration/global-setup.ts | 40 +++++ .../tests/integration/global-teardown.ts | 36 +++++ .../src/client/tests/integration/helpers.ts | 141 +++++++++++------- .../tests/integration/management.test.ts | 102 +++---------- .../client/tests/integration/search.test.ts | 23 +++ 10 files changed, 287 insertions(+), 230 deletions(-) create mode 100644 mem0-ts/jest.integration.config.js delete mode 100644 mem0-ts/src/client/tests/integration/feedback-and-export.test.ts create mode 100644 mem0-ts/src/client/tests/integration/global-setup.ts create mode 100644 mem0-ts/src/client/tests/integration/global-teardown.ts diff --git a/.github/workflows/ts-sdk-ci.yml b/.github/workflows/ts-sdk-ci.yml index e49cc7eb4..f236b13da 100644 --- a/.github/workflows/ts-sdk-ci.yml +++ b/.github/workflows/ts-sdk-ci.yml @@ -57,11 +57,15 @@ jobs: working-directory: mem0-ts run: pnpm run build - - name: Run tests + - name: Run unit tests + working-directory: mem0-ts + run: pnpm run test:ci -- --testPathIgnorePatterns=integration + + - name: Run integration tests (with cleanup) working-directory: mem0-ts env: MEM0_API_KEY: ${{ secrets.MEM0_API_KEY }} - run: pnpm run test:ci + run: pnpm run test:integration - name: Verify package exports working-directory: mem0-ts diff --git a/mem0-ts/jest.integration.config.js b/mem0-ts/jest.integration.config.js new file mode 100644 index 000000000..8c5894749 --- /dev/null +++ b/mem0-ts/jest.integration.config.js @@ -0,0 +1,9 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + ...require("./jest.config"), + testMatch: ["**/integration/**/*.test.ts"], + globalSetup: + "/src/client/tests/integration/global-setup.ts", + globalTeardown: + "/src/client/tests/integration/global-teardown.ts", +}; diff --git a/mem0-ts/package.json b/mem0-ts/package.json index e94573e52..be5bb3f65 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -38,6 +38,7 @@ "example": "ts-node src/oss/examples/vector-stores/index.ts", "test": "jest", "test:ci": "jest --coverage --ci", + "test:integration": "jest --config jest.integration.config.js --forceExit", "test:ts": "jest --config jest.config.js", "test:watch": "jest --config jest.config.js --watch", "format": "npm run clean && prettier --write .", diff --git a/mem0-ts/src/client/tests/integration/crud.test.ts b/mem0-ts/src/client/tests/integration/crud.test.ts index 53f9ba16b..4c1b60663 100644 --- a/mem0-ts/src/client/tests/integration/crud.test.ts +++ b/mem0-ts/src/client/tests/integration/crud.test.ts @@ -132,6 +132,17 @@ describeIntegration("MemoryClient Integration — CRUD", () => { expect(typeof mem.memory).toBe("string"); } }); + + test("returns paginated results with page and page_size", async () => { + const page1 = await client.getAll({ + user_id: TEST_USER_ID, + page: 1, + page_size: 1, + }); + + // Paginated response is an object with results array + expect(page1).toBeDefined(); + }); }); // ─── Update ─────────────────────────────────────────────── @@ -151,6 +162,19 @@ describeIntegration("MemoryClient Integration — CRUD", () => { expect(typeof updated.memory).toBe("string"); expect(updated.memory).not.toBe(originalText); }); + + test("updates memory metadata", async () => { + const memoryId = memoryIds[0]; + + await client.update(memoryId, { + metadata: { source: "integration-test", priority: "high" }, + }); + + const updated = await client.get(memoryId); + expect(updated.metadata).toBeDefined(); + expect(updated.metadata.source).toBe("integration-test"); + expect(updated.metadata.priority).toBe("high"); + }); }); // ─── Delete single ──────────────────────────────────────── @@ -170,6 +194,43 @@ describeIntegration("MemoryClient Integration — CRUD", () => { }); }); + // ─── Edge cases ────────────────────────────────────────── + describe("edge cases", () => { + test("add with metadata attaches metadata to the memory", async () => { + const result = await client.add( + [ + { role: "user" as const, content: "I prefer dark mode in all apps." }, + { role: "assistant" as const, content: "Noted, dark mode preference saved!" }, + ], + { + user_id: TEST_USER_ID, + metadata: { source: "integration-test", category: "preferences" }, + }, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + test("getAll for non-existent user returns empty array", async () => { + const memories = await client.getAll({ + user_id: `nonexistent-user-${randomUUID()}`, + }); + + expect(Array.isArray(memories)).toBe(true); + expect(memories.length).toBe(0); + }); + + test("deleteAll for non-existent user does not throw", async () => { + const result = await client.deleteAll({ + user_id: `nonexistent-user-${randomUUID()}`, + }); + + expect(result).toBeDefined(); + expect(typeof result.message).toBe("string"); + }); + }); + // ─── Delete all + delete user ───────────────────────────── describe("cleanup operations", () => { test("deletes all memories for test user", async () => { diff --git a/mem0-ts/src/client/tests/integration/feedback-and-export.test.ts b/mem0-ts/src/client/tests/integration/feedback-and-export.test.ts deleted file mode 100644 index abe15a710..000000000 --- a/mem0-ts/src/client/tests/integration/feedback-and-export.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Integration tests: Feedback and memory export. - * - * Tests feedback submission and the export lifecycle - * (create → retrieve) against the real API. - * - * Run: MEM0_API_KEY=your-key npx jest feedback-and-export.test.ts --forceExit - */ -import { MemoryClient } from "../../mem0"; -import { Feedback } from "../../mem0.types"; -import { randomUUID } from "crypto"; -import { - describeIntegration, - createTestClient, - suppressTelemetryNoise, - seedTestMemories, - cleanupTestUser, -} from "./helpers"; - -jest.setTimeout(120_000); - -const TEST_USER_ID = `integration-fb-export-${randomUUID()}`; - -describeIntegration("MemoryClient Integration — Feedback & Export", () => { - let client: MemoryClient; - let cleanup: () => void; - let memoryIds: string[] = []; - - beforeAll(async () => { - cleanup = suppressTelemetryNoise(); - client = createTestClient(); - memoryIds = await seedTestMemories(client, TEST_USER_ID); - }); - - afterAll(async () => { - await cleanupTestUser(client, TEST_USER_ID); - cleanup(); - }); - - // ─── Feedback ───────────────────────────────────────────── - // Note: client.feedback() is deprecated and doesn't send org_id/project_id, - // so we call _fetchWithErrorHandling directly to test the API endpoint. - describe("feedback", () => { - test("submits positive feedback on a memory", async () => { - const memoryId = memoryIds[0]; - expect(memoryId).toBeDefined(); - - const payload = { - memory_id: memoryId, - feedback: Feedback.POSITIVE, - org_id: String(client.organizationId), - project_id: String(client.projectId), - }; - - const result = await (client as any)._fetchWithErrorHandling( - `${client.host}/v1/feedback/`, - { - method: "POST", - headers: client.headers, - body: JSON.stringify(payload), - }, - ); - - expect(result).toBeDefined(); - }); - }); - - // ─── Export ─────────────────────────────────────────────── - describe("memory export", () => { - let exportId: string | undefined; - - test("creates a memory export", async () => { - const result = await client.createMemoryExport({ - filters: { AND: [{ user_id: TEST_USER_ID }] }, - schema: { memory: "string", user_id: "string" }, - }); - - expect(result).toBeDefined(); - expect( - typeof result.id === "string" || typeof result.message === "string", - ).toBe(true); - - exportId = result.id; - }); - - test("retrieves a memory export", async () => { - if (!exportId) return; - - const result = await client.getMemoryExport({ - memory_export_id: exportId, - }); - - expect(result).toBeDefined(); - }); - }); -}); diff --git a/mem0-ts/src/client/tests/integration/global-setup.ts b/mem0-ts/src/client/tests/integration/global-setup.ts new file mode 100644 index 000000000..215c61d8a --- /dev/null +++ b/mem0-ts/src/client/tests/integration/global-setup.ts @@ -0,0 +1,40 @@ +/** + * Jest global setup for integration tests. + * + * Runs a full project cleanup before any integration test starts, + * then waits 10 seconds for the async cleanup to propagate. + */ +import { MemoryClient } from "../../mem0"; + +export default async function globalSetup() { + const apiKey = process.env.MEM0_API_KEY; + if (!apiKey) return; // skip if no key — tests will be skipped too + + const client = new MemoryClient({ apiKey }); + await client.ping(); + + console.log("[integration] Running pre-test cleanup..."); + + // Full project wipe — all four filters set explicitly + try { + await client.deleteAll({ + user_id: "*", + agent_id: "*", + app_id: "*", + run_id: "*", + }); + } catch { + // ignore — may 404 if no data exists + } + + try { + await client.deleteUsers(); + } catch { + // ignore — may throw "No entities to delete" + } + + // Wait 10 seconds for async cleanup to propagate + console.log("[integration] Waiting 10s for cleanup to propagate..."); + await new Promise((r) => setTimeout(r, 10_000)); + console.log("[integration] Pre-test cleanup done."); +} diff --git a/mem0-ts/src/client/tests/integration/global-teardown.ts b/mem0-ts/src/client/tests/integration/global-teardown.ts new file mode 100644 index 000000000..9e94af661 --- /dev/null +++ b/mem0-ts/src/client/tests/integration/global-teardown.ts @@ -0,0 +1,36 @@ +/** + * Jest global teardown for integration tests. + * + * Runs a full project cleanup after all integration tests complete + * so no test data is left behind. + */ +import { MemoryClient } from "../../mem0"; + +export default async function globalTeardown() { + const apiKey = process.env.MEM0_API_KEY; + if (!apiKey) return; + + const client = new MemoryClient({ apiKey }); + await client.ping(); + + console.log("[integration] Running post-test cleanup..."); + + try { + await client.deleteAll({ + user_id: "*", + agent_id: "*", + app_id: "*", + run_id: "*", + }); + } catch { + // ignore + } + + try { + await client.deleteUsers(); + } catch { + // ignore + } + + console.log("[integration] Post-test cleanup done."); +} diff --git a/mem0-ts/src/client/tests/integration/helpers.ts b/mem0-ts/src/client/tests/integration/helpers.ts index af8c44f13..e1fcf9001 100644 --- a/mem0-ts/src/client/tests/integration/helpers.ts +++ b/mem0-ts/src/client/tests/integration/helpers.ts @@ -4,8 +4,7 @@ * Provides environment gating, client factory, polling helpers, * and console suppression for telemetry noise. * - * API credit budget: these helpers are designed to minimize API calls. - * Each CI run should use ~40 calls total across all test files. + * All helpers use only the SDK's public API — no internal method access. */ import { MemoryClient } from "../../mem0"; import type { Memory } from "../../mem0.types"; @@ -19,35 +18,33 @@ export const describeIntegration = API_KEY ? describe : describe.skip; * Create a MemoryClient with the real API key. * Call this inside beforeAll — not at module scope — so it only * runs when the suite is not skipped. - * - * The returned client retries transient errors (502, 503, 504, 429) - * with backoff so CI runs are not flaky. */ export function createTestClient(): MemoryClient { - const client = new MemoryClient({ apiKey: API_KEY! }); + return new MemoryClient({ apiKey: API_KEY! }); +} - const originalFetch = (client as any)._fetchWithErrorHandling.bind(client); - (client as any)._fetchWithErrorHandling = async ( - url: string, - options: any, - ) => { - const MAX_RETRIES = 2; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - return await originalFetch(url, options); - } catch (error: any) { - const isTransient = - error instanceof NetworkError || error instanceof RateLimitError; - if (isTransient && attempt < MAX_RETRIES) { - await new Promise((r) => setTimeout(r, 3_000 * attempt)); - continue; - } - throw error; +/** + * Retry an async SDK call on transient errors (NetworkError, RateLimitError). + * Use this to wrap any SDK call that may flake in CI. + */ +export async function withRetry( + fn: () => Promise, + maxRetries = 2, +): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error: any) { + const isTransient = + error instanceof NetworkError || error instanceof RateLimitError; + if (isTransient && attempt < maxRetries) { + await new Promise((r) => setTimeout(r, 3_000 * attempt)); + continue; } + throw error; } - }; - - return client; + } + throw new Error("withRetry: unreachable"); } /** @@ -63,7 +60,9 @@ export async function waitForMemories( ): Promise { const start = Date.now(); while (Date.now() - start < maxWaitMs) { - const memories = await client.getAll({ user_id: userId }); + const memories = await withRetry(() => + client.getAll({ user_id: userId }), + ); if (Array.isArray(memories) && memories.length >= minCount) { return memories; } @@ -84,7 +83,7 @@ export async function waitForSearchResults( ): Promise { const start = Date.now(); while (Date.now() - start < maxWaitMs) { - const results = await client.search(query, options); + const results = await withRetry(() => client.search(query, options)); if (Array.isArray(results) && results.length > 0) { return results; } @@ -129,33 +128,38 @@ export async function seedTestMemories( client: MemoryClient, userId: string, ): Promise { - await client.add( - [ - { - role: "user" as const, - content: "Hi, I'm integration-test-user. My favorite color is blue.", - }, - { - role: "assistant" as const, - content: - "Nice to meet you! I'll remember that your favorite color is blue.", - }, - ], - { user_id: userId }, + await withRetry(() => + client.add( + [ + { + role: "user" as const, + content: + "Hi, I'm integration-test-user. My favorite color is blue.", + }, + { + role: "assistant" as const, + content: + "Nice to meet you! I'll remember that your favorite color is blue.", + }, + ], + { user_id: userId }, + ), ); - await client.add( - [ - { - role: "user" as const, - content: "I work as a software engineer at Acme Corp.", - }, - { - role: "assistant" as const, - content: "Got it, you're a software engineer at Acme Corp!", - }, - ], - { user_id: userId }, + await withRetry(() => + client.add( + [ + { + role: "user" as const, + content: "I work as a software engineer at Acme Corp.", + }, + { + role: "assistant" as const, + content: "Got it, you're a software engineer at Acme Corp!", + }, + ], + { user_id: userId }, + ), ); const memories = await waitForMemories(client, userId, 1); @@ -180,3 +184,34 @@ export async function cleanupTestUser( // ignore } } + +/** + * Full project wipe — deletes all memories and all entities. + * Equivalent to Python SDK's: + * client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") + * + * Used as cleanup before and after integration test runs so tests + * start from a clean slate and don't leave data behind. + */ +export async function fullProjectCleanup( + client: MemoryClient, +): Promise { + // Delete all memories — all four filters set explicitly + try { + await client.deleteAll({ + user_id: "*", + agent_id: "*", + app_id: "*", + run_id: "*", + }); + } catch { + // ignore — may 404 if no data exists + } + + // Delete all entities (users, agents, apps, runs) + try { + await client.deleteUsers(); + } catch { + // ignore — may throw "No entities to delete" + } +} diff --git a/mem0-ts/src/client/tests/integration/management.test.ts b/mem0-ts/src/client/tests/integration/management.test.ts index d6347a350..c49a9c213 100644 --- a/mem0-ts/src/client/tests/integration/management.test.ts +++ b/mem0-ts/src/client/tests/integration/management.test.ts @@ -1,16 +1,15 @@ /** - * Integration tests: User management, project, and webhooks. + * Integration tests: User management and project configuration. * - * Tests users(), getProject(), and the full webhook lifecycle - * (create → list → update → delete) against the real API. + * Tests users(), getProject(), and updateProject() against the real API. * - * Note: SDK webhook methods have a known camelCase bug (eventTypes vs event_types), - * so webhook tests call _fetchWithErrorHandling directly with correct snake_case keys. + * 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. * * Run: MEM0_API_KEY=your-key npx jest management.test.ts --forceExit */ import { MemoryClient } from "../../mem0"; -import type { Webhook } from "../../mem0.types"; import { randomUUID } from "crypto"; import { describeIntegration, @@ -25,11 +24,10 @@ jest.setTimeout(120_000); const TEST_USER_ID = `integration-mgmt-${randomUUID()}`; describeIntegration( - "MemoryClient Integration — Users, Project & Webhooks", + "MemoryClient Integration — Users & Project", () => { let client: MemoryClient; let cleanup: () => void; - let webhookId: string | undefined; beforeAll(async () => { cleanup = suppressTelemetryNoise(); @@ -38,13 +36,6 @@ describeIntegration( }); afterAll(async () => { - if (webhookId) { - try { - await client.deleteWebhook({ webhookId }); - } catch { - // ignore - } - } await cleanupTestUser(client, TEST_USER_ID); cleanup(); }); @@ -71,6 +62,8 @@ describeIntegration( // ─── Project ────────────────────────────────────────────── describe("project management", () => { + let originalInstructions: string | undefined; + test("gets project with custom_instructions field", async () => { const project = await client.getProject({ fields: ["custom_instructions"], @@ -79,78 +72,29 @@ describeIntegration( expect(project).toBeDefined(); expect(typeof project).toBe("object"); expect("custom_instructions" in project).toBe(true); - }); - }); - // ─── Webhooks ───────────────────────────────────────────── - // SDK's createWebhook/updateWebhook send camelCase eventTypes but - // API expects snake_case event_types, so we call the API directly. - describe("webhook lifecycle", () => { - const webhookUrl = `https://example.com/webhook-test-${randomUUID()}`; - - test("creates a webhook", async () => { - const payload = { - name: `integration-test-webhook-${randomUUID().slice(0, 8)}`, - url: webhookUrl, - event_types: ["memory_add", "memory_update"], - }; - - const created = await (client as any)._fetchWithErrorHandling( - `${client.host}/api/v1/webhooks/projects/${client.projectId}/`, - { - method: "POST", - headers: client.headers, - body: JSON.stringify(payload), - }, - ); - - expect(created).toBeDefined(); - expect(typeof created.webhook_id).toBe("string"); - - webhookId = created.webhook_id; + originalInstructions = project.custom_instructions; }); - test("lists webhooks and finds the created one", async () => { - const webhooks = await client.getWebhooks(); + test("updates project custom_instructions via updateProject()", async () => { + const testInstruction = `integration-test-${randomUUID().slice(0, 8)}`; - expect(Array.isArray(webhooks)).toBe(true); - - if (webhookId) { - const found = webhooks.find( - (w: Webhook) => w.webhook_id === webhookId, - ); - expect(found).toBeDefined(); - } - }); - - test("updates a webhook", async () => { - if (!webhookId) return; - - const payload = { - name: `updated-webhook-${randomUUID().slice(0, 8)}`, - url: webhookUrl, - event_types: ["memory_add", "memory_update", "memory_delete"], - }; - - const result = await (client as any)._fetchWithErrorHandling( - `${client.host}/api/v1/webhooks/${webhookId}/`, - { - method: "PUT", - headers: client.headers, - body: JSON.stringify(payload), - }, - ); + const result = await client.updateProject({ + custom_instructions: testInstruction, + }); expect(result).toBeDefined(); - }); - test("deletes the webhook", async () => { - if (!webhookId) return; + // Verify the update took effect + const project = await client.getProject({ + fields: ["custom_instructions"], + }); + expect(project.custom_instructions).toBe(testInstruction); - const result = await client.deleteWebhook({ webhookId }); - expect(result).toBeDefined(); - - webhookId = undefined; + // Restore original + await client.updateProject({ + custom_instructions: originalInstructions || "", + }); }); }); }, diff --git a/mem0-ts/src/client/tests/integration/search.test.ts b/mem0-ts/src/client/tests/integration/search.test.ts index 9d085a31b..ad069e006 100644 --- a/mem0-ts/src/client/tests/integration/search.test.ts +++ b/mem0-ts/src/client/tests/integration/search.test.ts @@ -105,4 +105,27 @@ describeIntegration("MemoryClient Integration — Search & History", () => { expect(events).toContain("ADD"); }); }); + + // ─── Edge cases ───────────────────────────────────────── + describe("edge cases", () => { + test("search for non-existent user returns empty results", async () => { + const results = await client.search("anything", { + user_id: `nonexistent-user-${randomUUID()}`, + }); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBe(0); + }); + + test("search with limit returns at most that many results", async () => { + const results = await waitForSearchResults( + client, + "Tell me about integration test user", + { user_id: TEST_USER_ID, limit: 1 }, + ); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBeLessThanOrEqual(1); + }); + }); });