diff --git a/.github/workflows/ts-sdk-ci.yml b/.github/workflows/ts-sdk-ci.yml index a993a120c..c1fb0d0c8 100644 --- a/.github/workflows/ts-sdk-ci.yml +++ b/.github/workflows/ts-sdk-ci.yml @@ -57,9 +57,9 @@ 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 + run: pnpm run test:unit - name: Verify package exports working-directory: mem0-ts @@ -73,3 +73,38 @@ jobs: with: name: coverage-report path: mem0-ts/coverage/ + + integration_ts_sdk: + needs: build_ts_sdk + runs-on: ubuntu-latest + strategy: + max-parallel: 1 + matrix: + node-version: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + cache-dependency-path: mem0-ts/pnpm-lock.yaml + + - name: Install dependencies + working-directory: mem0-ts + run: pnpm install --frozen-lockfile + + - name: Build + working-directory: mem0-ts + run: pnpm run build + + - name: Run integration tests (with cleanup) + working-directory: mem0-ts + env: + MEM0_API_KEY: ${{ secrets.MEM0_API_KEY }} + run: pnpm run test:integration diff --git a/mem0-ts/jest.integration.config.js b/mem0-ts/jest.integration.config.js new file mode 100644 index 000000000..50eb8e21e --- /dev/null +++ b/mem0-ts/jest.integration.config.js @@ -0,0 +1,7 @@ +/** @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..8c9e6012e 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -38,6 +38,8 @@ "example": "ts-node src/oss/examples/vector-stores/index.ts", "test": "jest", "test:ci": "jest --coverage --ci", + "test:unit": "jest --coverage --ci --testPathIgnorePatterns='/node_modules/' '/dist/' 'integration'", + "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/batch.test.ts b/mem0-ts/src/client/tests/integration/batch.test.ts new file mode 100644 index 000000000..22de6124e --- /dev/null +++ b/mem0-ts/src/client/tests/integration/batch.test.ts @@ -0,0 +1,64 @@ +/** + * Integration tests: Batch operations. + * + * Tests batch update and batch delete against the real API. + * + * Run: MEM0_API_KEY=your-key npx jest batch.test.ts --forceExit + */ +import { MemoryClient } from "../../mem0"; +import { randomUUID } from "crypto"; +import { + describeIntegration, + createTestClient, + suppressTelemetryNoise, + seedTestMemories, + cleanupTestUser, +} from "./helpers"; + +jest.setTimeout(120_000); + +const TEST_USER_ID = `integration-batch-${randomUUID()}`; + +describeIntegration("MemoryClient Integration — Batch Operations", () => { + 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(); + }); + + test("batch updates memories", async () => { + expect(memoryIds.length).toBeGreaterThanOrEqual(1); + + const batchPayload = memoryIds + .slice(0, Math.min(2, memoryIds.length)) + .map((id) => ({ + memoryId: id, + text: `Batch updated content for ${id}`, + })); + + const result = await client.batchUpdate(batchPayload); + expect(result).toBeDefined(); + + // Verify the update took effect on at least one memory + const updated = await client.get(memoryIds[0]); + expect(typeof updated.memory).toBe("string"); + }); + + test("batch deletes memories that exist", async () => { + // Use one of the seeded memory IDs that we know exists + expect(memoryIds.length).toBeGreaterThanOrEqual(1); + + const toDelete = [memoryIds[memoryIds.length - 1]]; + const result = await client.batchDelete(toDelete); + expect(result).toBeDefined(); + }); +}); diff --git a/mem0-ts/src/client/tests/integration/crud.test.ts b/mem0-ts/src/client/tests/integration/crud.test.ts new file mode 100644 index 000000000..8600d6571 --- /dev/null +++ b/mem0-ts/src/client/tests/integration/crud.test.ts @@ -0,0 +1,251 @@ +/** + * Integration tests: Memory CRUD operations. + * + * Tests the full lifecycle: add → get → getAll → update → delete. + * Validates response shapes against the real API. + * + * Run: MEM0_API_KEY=your-key npx jest crud.test.ts --forceExit + */ +import { MemoryClient } from "../../mem0"; +import { MemoryError } from "../../../common/exceptions"; +import { randomUUID } from "crypto"; +import { + describeIntegration, + createTestClient, + suppressTelemetryNoise, + waitForMemories, + cleanupTestUser, +} from "./helpers"; + +jest.setTimeout(120_000); + +const TEST_USER_ID = `integration-crud-${randomUUID()}`; + +describeIntegration("MemoryClient Integration — CRUD", () => { + let client: MemoryClient; + let cleanup: () => void; + let memoryIds: string[] = []; + + beforeAll(() => { + cleanup = suppressTelemetryNoise(); + client = createTestClient(); + }); + + afterAll(async () => { + await cleanupTestUser(client, TEST_USER_ID); + cleanup(); + }); + + // ─── Add ────────────────────────────────────────────────── + describe("add memories", () => { + test("add returns a pending response with event_id", async () => { + const messages = [ + { + 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.", + }, + ]; + + const result = await client.add(messages, { user_id: TEST_USER_ID }); + + // API processes memories asynchronously — returns PENDING + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + // Validate response shape + for (const item of result) { + expect(item).toHaveProperty("status"); + expect(item).toHaveProperty("event_id"); + } + }); + + test("adds a second batch of messages", async () => { + const messages = [ + { + 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!", + }, + ]; + + const result = await client.add(messages, { user_id: TEST_USER_ID }); + expect(Array.isArray(result)).toBe(true); + }); + + test("memories become available after async processing", async () => { + const memories = await waitForMemories(client, TEST_USER_ID, 1); + + expect(memories.length).toBeGreaterThan(0); + + // Store IDs for later tests + memoryIds = memories.map((m) => m.id); + expect(memoryIds.length).toBeGreaterThan(0); + expect(typeof memoryIds[0]).toBe("string"); + }); + }); + + // ─── Get by ID ──────────────────────────────────────────── + describe("get memory by ID", () => { + test("retrieves a specific memory with correct shape", async () => { + const memoryId = memoryIds[0]; + expect(memoryId).toBeDefined(); + + const memory = await client.get(memoryId); + + expect(memory.id).toBe(memoryId); + expect(typeof memory.memory).toBe("string"); + expect(memory.memory!.length).toBeGreaterThan(0); + expect(typeof memory.user_id).toBe("string"); + expect( + memory.metadata === null || typeof memory.metadata === "object", + ).toBe(true); + expect( + Array.isArray(memory.categories) || memory.categories === null, + ).toBe(true); + expect(new Date(memory.created_at || "").toString()).not.toBe( + "Invalid Date", + ); + expect(new Date(memory.updated_at || "").toString()).not.toBe( + "Invalid Date", + ); + }); + }); + + // ─── Get all ────────────────────────────────────────────── + describe("get all memories", () => { + test("returns all memories for test user", async () => { + const memories = await client.getAll({ user_id: TEST_USER_ID }); + + expect(Array.isArray(memories)).toBe(true); + expect(memories.length).toBeGreaterThanOrEqual(memoryIds.length); + + for (const mem of memories) { + expect(typeof mem.id).toBe("string"); + 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 ─────────────────────────────────────────────── + describe("update memory", () => { + test("updates memory text and verifies the content changed", async () => { + const memoryId = memoryIds[0]; + + // Read original text before update + const original = await client.get(memoryId); + const originalText = original.memory; + + await client.update(memoryId, { + text: "My favorite color is green (updated)", + }); + + const updated = await client.get(memoryId); + 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 ──────────────────────────────────────── + 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 () => { + 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 () => { + const result = await client.deleteAll({ user_id: TEST_USER_ID }); + expect(result).toBeDefined(); + expect(typeof result.message).toBe("string"); + }); + + test("deletes the test user entity", async () => { + const result = await client.deleteUsers({ user_id: TEST_USER_ID }); + expect(result).toBeDefined(); + expect(result.message).toBe("Entity deleted successfully."); + }); + }); +}); 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 new file mode 100644 index 000000000..d74156e7d --- /dev/null +++ b/mem0-ts/src/client/tests/integration/helpers.ts @@ -0,0 +1,212 @@ +/** + * Shared helpers for MemoryClient real integration tests. + * + * Provides environment gating, client factory, polling helpers, + * and console suppression for telemetry noise. + * + * All helpers use only the SDK's public API — no internal method access. + */ +import { MemoryClient } from "../../mem0"; +import type { Memory } from "../../mem0.types"; +import { NetworkError, RateLimitError } from "../../../common/exceptions"; + +// ─── Environment gate ──────────────────────────────────── +export const API_KEY = process.env.MEM0_API_KEY; +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. + */ +export function createTestClient(): MemoryClient { + return new MemoryClient({ apiKey: API_KEY! }); +} + +/** + * 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; + } + } + throw new Error("withRetry: unreachable"); +} + +/** + * 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. + */ +export async function waitForMemories( + client: MemoryClient, + userId: string, + minCount: number, + maxWaitMs = 60_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + 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)); + } + return await client.getAll({ user_id: userId }); +} + +/** + * Poll search until results appear. Only used by search tests — + * other test files should NOT call this to avoid wasting API credits. + */ +export async function waitForSearchResults( + client: MemoryClient, + query: string, + options: Record, + maxWaitMs = 60_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + 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)); + } + return await client.search(query, options); +} + +/** + * Suppress telemetry console noise during tests. + * Returns a cleanup function to call in afterAll. + */ +export function suppressTelemetryNoise(): () => void { + const originalError = console.error; + const originalWarn = console.warn; + + jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + if ( + String(args[0] ?? "").match( + /Telemetry|Failed to initialize|Failed to capture/, + ) + ) + return; + originalError(...args); + }); + jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + if (String(args[0] ?? "").match(/telemetry|Telemetry/)) return; + originalWarn(...args); + }); + + return () => jest.restoreAllMocks(); +} + +/** + * Add test memories and wait for them to be processed. + * Returns the memory IDs once available via getAll. + * + * NOTE: This only waits for the listing index. If your test needs + * search results, call waitForSearchResults() separately. + */ +export async function seedTestMemories( + client: MemoryClient, + userId: string, +): Promise { + 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 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); + return memories.map((m) => m.id); +} + +/** + * Clean up all test data for a user. Best-effort — ignores errors. + */ +export async function cleanupTestUser( + client: MemoryClient, + userId: string, +): Promise { + try { + await client.deleteAll({ user_id: userId }); + } catch { + // ignore + } + try { + await client.deleteUsers({ user_id: userId }); + } catch { + // 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/initialization.test.ts b/mem0-ts/src/client/tests/integration/initialization.test.ts new file mode 100644 index 000000000..6336d0c11 --- /dev/null +++ b/mem0-ts/src/client/tests/integration/initialization.test.ts @@ -0,0 +1,63 @@ +/** + * Integration tests: Client initialization and error handling. + * + * Tests ping, org/project resolution, and invalid credentials. + * These tests do NOT need pre-seeded memories. + * + * Run: MEM0_API_KEY=your-key npx jest initialization.test.ts --forceExit + */ +import { MemoryClient } from "../../mem0"; +import { + MemoryError, + MemoryNotFoundError, + ValidationError, +} from "../../../common/exceptions"; +import { + describeIntegration, + createTestClient, + suppressTelemetryNoise, +} from "./helpers"; + +jest.setTimeout(60_000); + +describeIntegration("MemoryClient Integration — Initialization", () => { + let client: MemoryClient; + let cleanup: () => void; + + beforeAll(() => { + cleanup = suppressTelemetryNoise(); + client = createTestClient(); + }); + + afterAll(() => cleanup()); + + test("client pings successfully and resolves org/project", async () => { + await client.ping(); + expect(client.organizationId).toBeTruthy(); + expect(client.projectId).toBeTruthy(); + }); + + test("get with invalid ID throws ValidationError", async () => { + // Non-UUID string triggers a 400 ValidationError, not a 404 + await expect(client.get("nonexistent-memory-id-12345")).rejects.toThrow( + ValidationError, + ); + }); + + test("get with non-existent UUID throws MemoryNotFoundError", async () => { + await expect( + client.get("00000000-0000-0000-0000-000000000000"), + ).rejects.toThrow(MemoryNotFoundError); + }); + + test("all SDK exceptions are MemoryError subclasses", async () => { + await expect(client.get("nonexistent-memory-id-12345")).rejects.toThrow( + MemoryError, + ); + }); + + test("invalid API key throws on ping", async () => { + const badClient = new MemoryClient({ apiKey: "invalid-key-12345" }); + await expect(badClient.ping()).rejects.toThrow(); + }); +}); diff --git a/mem0-ts/src/client/tests/integration/management.test.ts b/mem0-ts/src/client/tests/integration/management.test.ts new file mode 100644 index 000000000..95df93b6f --- /dev/null +++ b/mem0-ts/src/client/tests/integration/management.test.ts @@ -0,0 +1,98 @@ +/** + * Integration tests: User management and project configuration. + * + * 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. + * + * Run: MEM0_API_KEY=your-key npx jest management.test.ts --forceExit + */ +import { MemoryClient } from "../../mem0"; +import { randomUUID } from "crypto"; +import { + describeIntegration, + createTestClient, + suppressTelemetryNoise, + seedTestMemories, + cleanupTestUser, +} from "./helpers"; + +jest.setTimeout(120_000); + +const TEST_USER_ID = `integration-mgmt-${randomUUID()}`; + +describeIntegration("MemoryClient Integration — Users & Project", () => { + let client: MemoryClient; + let cleanup: () => void; + + beforeAll(async () => { + cleanup = suppressTelemetryNoise(); + client = createTestClient(); + await seedTestMemories(client, TEST_USER_ID); + }); + + afterAll(async () => { + await cleanupTestUser(client, TEST_USER_ID); + cleanup(); + }); + + // ─── Users ──────────────────────────────────────────────── + describe("user management", () => { + test("lists users and finds test user", async () => { + const allUsers = await client.users(); + + expect(typeof allUsers.count).toBe("number"); + expect(Array.isArray(allUsers.results)).toBe(true); + + if (allUsers.results.length > 0) { + const user = allUsers.results[0]; + expect(typeof user.id).toBe("string"); + expect(typeof user.name).toBe("string"); + expect(typeof user.type).toBe("string"); + } + + const testUser = allUsers.results.find((u) => u.name === TEST_USER_ID); + expect(testUser).toBeDefined(); + }); + }); + + // ─── Project ────────────────────────────────────────────── + describe("project management", () => { + let originalInstructions: string | undefined; + + test("gets project with custom_instructions field", async () => { + const project = await client.getProject({ + fields: ["custom_instructions"], + }); + + expect(project).toBeDefined(); + expect(typeof project).toBe("object"); + expect("custom_instructions" in project).toBe(true); + + originalInstructions = project.custom_instructions; + }); + + test("updates project custom_instructions via updateProject()", async () => { + const testInstruction = `integration-test-${randomUUID().slice(0, 8)}`; + + const result = await client.updateProject({ + custom_instructions: testInstruction, + }); + + expect(result).toBeDefined(); + + // Verify the update took effect + const project = await client.getProject({ + fields: ["custom_instructions"], + }); + expect(project.custom_instructions).toBe(testInstruction); + + // 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 new file mode 100644 index 000000000..ac572f63d --- /dev/null +++ b/mem0-ts/src/client/tests/integration/search.test.ts @@ -0,0 +1,132 @@ +/** + * Integration tests: Search and history operations. + * + * Tests search v1, search v2, and memory history against the real API. + * + * Run: MEM0_API_KEY=your-key npx jest search.test.ts --forceExit + */ +import { MemoryClient } from "../../mem0"; +import { randomUUID } from "crypto"; +import { + describeIntegration, + createTestClient, + suppressTelemetryNoise, + seedTestMemories, + cleanupTestUser, + waitForSearchResults, +} from "./helpers"; + +jest.setTimeout(120_000); + +const TEST_USER_ID = `integration-search-${randomUUID()}`; + +describeIntegration("MemoryClient Integration — Search & History", () => { + 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(); + }); + + // ─── Search v1 ──────────────────────────────────────────── + describe("search v1", () => { + test("searches memories by user_id and returns results with scores", async () => { + // Search index may lag behind listing index — poll until ready + const results = await waitForSearchResults( + client, + "What is my favorite color?", + { user_id: TEST_USER_ID }, + ); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBeGreaterThan(0); + + const first = results[0]; + expect(typeof first.id).toBe("string"); + expect(typeof first.memory).toBe("string"); + expect(typeof first.score).toBe("number"); + expect(first.score).toBeGreaterThan(0); + }); + }); + + // ─── Search v2 ──────────────────────────────────────────── + describe("search v2", () => { + test("searches with OR filters and returns results", async () => { + const results = await waitForSearchResults( + client, + "What do you know about me?", + { + filters: { OR: [{ user_id: TEST_USER_ID }] }, + api_version: "v2", + }, + ); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBeGreaterThan(0); + + const first = results[0]; + expect(typeof first.id).toBe("string"); + expect(typeof first.memory).toBe("string"); + expect(typeof first.score).toBe("number"); + }); + }); + + // ─── History ────────────────────────────────────────────── + describe("memory history", () => { + test("returns history with at least an ADD event", async () => { + const memoryId = memoryIds[0]; + const history = await client.history(memoryId); + + expect(Array.isArray(history)).toBe(true); + expect(history.length).toBeGreaterThanOrEqual(1); + + const entry = history[0]; + expect(typeof entry.id).toBe("string"); + expect(typeof entry.memory_id).toBe("string"); + expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(entry.event); + expect(new Date(entry.created_at).toString()).not.toBe("Invalid Date"); + expect(new Date(entry.updated_at).toString()).not.toBe("Invalid Date"); + expect( + entry.new_memory === null || typeof entry.new_memory === "string", + ).toBe(true); + expect( + entry.old_memory === null || typeof entry.old_memory === "string", + ).toBe(true); + + const events = history.map((h) => h.event); + 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 param does not throw", async () => { + const results = await client.search( + "Tell me about integration test user", + { + user_id: TEST_USER_ID, + limit: 1, + }, + ); + + expect(Array.isArray(results)).toBe(true); + }); + }); +});