From 3cdcb6564cf29b1c464ad4ad0ba3d8e47ee4668c Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 17 Mar 2026 21:13:52 +0530 Subject: [PATCH] chore: end to end test coverage for ts sdk (#4357) --- .github/workflows/ts-sdk-ci.yml | 75 +++ mem0-ts/.gitignore | 6 + mem0-ts/.prettierignore | 4 + mem0-ts/package.json | 1 + mem0-ts/src/client/index.ts | 19 +- mem0-ts/src/client/mem0.ts | 11 +- mem0-ts/src/client/mem0.types.ts | 2 +- mem0-ts/src/client/tests/helpers.ts | 189 +++++++ .../client/tests/memoryClient.batch.test.ts | 103 ++++ .../client/tests/memoryClient.crud.test.ts | 380 +++++++++++++ .../src/client/tests/memoryClient.e2e.test.ts | 501 ++++++++++++++++++ .../client/tests/memoryClient.init.test.ts | 251 +++++++++ .../client/tests/memoryClient.project.test.ts | 251 +++++++++ .../client/tests/memoryClient.search.test.ts | 103 ++++ mem0-ts/src/client/tests/memoryClient.test.ts | 391 -------------- .../client/tests/memoryClient.users.test.ts | 185 +++++++ .../tests/memoryClient.webhooks.test.ts | 154 ++++++ mem0-ts/src/client/tests/setup.ts | 83 +++ mem0-ts/src/common/exceptions.test.ts | 246 +++++++++ mem0-ts/src/common/exceptions.ts | 205 +++++++ mem0-ts/src/oss/src/config/manager.ts | 12 +- .../src/tests/sqlite-backward-compat.test.ts | 2 +- mem0-ts/src/oss/src/vector_stores/qdrant.ts | 6 +- mem0-ts/src/oss/tests/config-manager.test.ts | 117 +++- mem0-ts/src/oss/tests/factory.test.ts | 67 --- mem0-ts/src/oss/tests/factory.unit.test.ts | 287 ++++++++++ .../src/oss/tests/lmstudio-embedder.test.ts | 10 +- .../oss/tests/lmstudio-integration.test.ts | 31 +- mem0-ts/src/oss/tests/lmstudio-llm.test.ts | 48 +- mem0-ts/src/oss/tests/memory.add.test.ts | 164 ++++++ mem0-ts/src/oss/tests/memory.crud.test.ts | 358 +++++++++++++ mem0-ts/src/oss/tests/memory.e2e.test.ts | 292 ++++++++++ mem0-ts/src/oss/tests/memory.init.test.ts | 125 +++++ mem0-ts/src/oss/tests/memory.test.ts | 256 --------- mem0-ts/src/oss/tests/storage.unit.test.ts | 228 ++++++++ .../src/oss/tests/vector-store.unit.test.ts | 198 +++++++ 36 files changed, 4583 insertions(+), 778 deletions(-) create mode 100644 .github/workflows/ts-sdk-ci.yml create mode 100644 mem0-ts/.gitignore create mode 100644 mem0-ts/.prettierignore create mode 100644 mem0-ts/src/client/tests/helpers.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.batch.test.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.crud.test.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.e2e.test.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.init.test.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.project.test.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.search.test.ts delete mode 100644 mem0-ts/src/client/tests/memoryClient.test.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.users.test.ts create mode 100644 mem0-ts/src/client/tests/memoryClient.webhooks.test.ts create mode 100644 mem0-ts/src/client/tests/setup.ts create mode 100644 mem0-ts/src/common/exceptions.test.ts create mode 100644 mem0-ts/src/common/exceptions.ts delete mode 100644 mem0-ts/src/oss/tests/factory.test.ts create mode 100644 mem0-ts/src/oss/tests/factory.unit.test.ts create mode 100644 mem0-ts/src/oss/tests/memory.add.test.ts create mode 100644 mem0-ts/src/oss/tests/memory.crud.test.ts create mode 100644 mem0-ts/src/oss/tests/memory.e2e.test.ts create mode 100644 mem0-ts/src/oss/tests/memory.init.test.ts delete mode 100644 mem0-ts/src/oss/tests/memory.test.ts create mode 100644 mem0-ts/src/oss/tests/storage.unit.test.ts create mode 100644 mem0-ts/src/oss/tests/vector-store.unit.test.ts diff --git a/.github/workflows/ts-sdk-ci.yml b/.github/workflows/ts-sdk-ci.yml new file mode 100644 index 000000000..a993a120c --- /dev/null +++ b/.github/workflows/ts-sdk-ci.yml @@ -0,0 +1,75 @@ +name: TypeScript SDK CI + +on: + push: + branches: [main] + paths: + - 'mem0-ts/**' + - '.github/workflows/ts-sdk-ci.yml' + pull_request: + paths: + - 'mem0-ts/**' + +jobs: + check_changes: + runs-on: ubuntu-latest + outputs: + ts_sdk_changed: ${{ steps.filter.outputs.ts_sdk }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + ts_sdk: + - 'mem0-ts/**' + + build_ts_sdk: + needs: check_changes + if: needs.check_changes.outputs.ts_sdk_changed == 'true' + runs-on: ubuntu-latest + strategy: + 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: Lint + working-directory: mem0-ts + run: npx prettier --check . + + - name: Build + working-directory: mem0-ts + run: pnpm run build + + - name: Run tests + working-directory: mem0-ts + run: pnpm run test:ci + + - name: Verify package exports + working-directory: mem0-ts + run: | + node -e "const m = require('./dist/index.js'); console.log('Client exports:', Object.keys(m).length)" + node -e "const m = require('./dist/oss/index.js'); console.log('OSS exports:', Object.keys(m).length)" + + - name: Upload coverage + if: matrix.node-version == 20 + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: mem0-ts/coverage/ diff --git a/mem0-ts/.gitignore b/mem0-ts/.gitignore new file mode 100644 index 000000000..035e105d9 --- /dev/null +++ b/mem0-ts/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +coverage/ +*.db +.env +.env.* diff --git a/mem0-ts/.prettierignore b/mem0-ts/.prettierignore new file mode 100644 index 000000000..3a5ab8b87 --- /dev/null +++ b/mem0-ts/.prettierignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +pnpm-lock.yaml diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 1a8184963..e94573e52 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -37,6 +37,7 @@ "start": "pnpm run example memory", "example": "ts-node src/oss/examples/vector-stores/index.ts", "test": "jest", + "test:ci": "jest --coverage --ci", "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/index.ts b/mem0-ts/src/client/index.ts index 7b8c36707..d39f068be 100644 --- a/mem0-ts/src/client/index.ts +++ b/mem0-ts/src/client/index.ts @@ -18,9 +18,26 @@ export type { AllUsers, User, FeedbackPayload, - Feedback, } from "./mem0.types"; +// Re-export enums as values (not type-only) +export { Feedback, WebhookEvent } from "./mem0.types"; + // Export the main client export { MemoryClient }; export default MemoryClient; + +// Export structured exceptions +export { + MemoryError, + AuthenticationError, + RateLimitError, + ValidationError, + MemoryNotFoundError, + NetworkError, + ConfigurationError, + MemoryQuotaExceededError, + createExceptionFromResponse, +} from "../common/exceptions"; + +export type { MemoryErrorOptions } from "../common/exceptions"; diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 1cd5a5aa4..a67d59e2d 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -17,6 +17,7 @@ import { GetMemoryExportPayload, } from "./mem0.types"; import { captureClientEvent, generateHash } from "./telemetry"; +import { createExceptionFromResponse, MemoryError } from "../common/exceptions"; class APIError extends Error { constructor(message: string) { @@ -155,7 +156,7 @@ export default class MemoryClient { }); if (!response.ok) { const errorData = await response.text(); - throw new APIError(`API request failed: ${errorData}`); + throw createExceptionFromResponse(response.status, errorData); } const jsonResponse = await response.json(); return jsonResponse; @@ -200,8 +201,8 @@ export default class MemoryClient { if (project_id && !this.projectId) this.projectId = project_id; if (user_email) this.telemetryId = user_email; } catch (error: any) { - // Convert generic errors to APIError with meaningful messages - if (error instanceof APIError) { + // Pass through structured exceptions and APIError + if (error instanceof MemoryError || error instanceof APIError) { throw error; } else { throw new APIError( @@ -310,7 +311,7 @@ export default class MemoryClient { this._validateOrgProject(); const payloadKeys = Object.keys(options || {}); this._captureEvent("get_all", [payloadKeys]); - const { api_version, page, page_size, ...otherOptions } = options!; + const { api_version, page, page_size, ...otherOptions } = options ?? {}; if (this.organizationName != null && this.projectName != null) { otherOptions.org_name = this.organizationName; otherOptions.project_name = this.projectName; @@ -361,7 +362,7 @@ export default class MemoryClient { this._validateOrgProject(); const payloadKeys = Object.keys(options || {}); this._captureEvent("search", [payloadKeys]); - const { api_version, ...otherOptions } = options!; + const { api_version, ...otherOptions } = options ?? {}; const payload = { query, ...otherOptions }; if (this.organizationName != null && this.projectName != null) { payload.org_name = this.organizationName; diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index 15d22d686..0b568a9ff 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -170,7 +170,7 @@ export interface PromptUpdatePayload { [key: string]: any; } -enum WebhookEvent { +export enum WebhookEvent { MEMORY_ADDED = "memory_add", MEMORY_UPDATED = "memory_update", MEMORY_DELETED = "memory_delete", diff --git a/mem0-ts/src/client/tests/helpers.ts b/mem0-ts/src/client/tests/helpers.ts new file mode 100644 index 000000000..42d960757 --- /dev/null +++ b/mem0-ts/src/client/tests/helpers.ts @@ -0,0 +1,189 @@ +/** + * Test helpers for MemoryClient unit tests. + * Provides mock fetch, factory functions, and constants. + */ + +// ─── Mock Fetch ────────────────────────────────────────── + +interface MockResponse { + status: number; + body: unknown; +} + +/** + * Creates a mock fetch function that matches URL patterns to responses. + * Patterns are matched using string includes, sorted longest-first + * so more specific routes (e.g. /v1/memories/search/) win over + * broader ones (e.g. /v1/memories/) regardless of insertion order. + */ +export function createMockFetch( + responses: Map, +): jest.Mock { + return jest.fn( + async (url: string | URL | Request, _options?: RequestInit) => { + const urlStr = + typeof url === "string" + ? url + : url instanceof URL + ? url.toString() + : url.url; + + // Sort patterns longest-first so specific routes match before broad ones + const sortedPatterns = [...responses.entries()].sort( + (a, b) => b[0].length - a[0].length, + ); + + for (const [pattern, response] of sortedPatterns) { + if (urlStr.includes(pattern)) { + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.status === 200 ? "OK" : "Error", + json: async () => response.body, + text: async () => + typeof response.body === "string" + ? response.body + : JSON.stringify(response.body), + } as Response; + } + } + + return { + ok: false, + status: 404, + statusText: "Not Found", + json: async () => ({ error: "Not found" }), + text: async () => "Not found", + } as Response; + }, + ); +} + +// ─── Factory Functions ─────────────────────────────────── + +export interface MockMemory { + id: string; + memory?: string; + data?: { memory: string } | null; + event?: string; + user_id?: string; + agent_id?: string | null; + app_id?: string | null; + run_id?: string | null; + hash?: string; + categories?: string[]; + created_at?: string; + updated_at?: string; + score?: number; + metadata?: Record | null; + owner?: string | null; +} + +export function createMockMemory( + overrides: Partial = {}, +): MockMemory { + return { + id: "mem_test_123", + memory: "Test memory content", + user_id: "user_test", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + categories: [], + metadata: null, + ...overrides, + }; +} + +export interface MockMemoryHistory { + id: string; + memory_id: string; + input: Array<{ role: string; content: string }>; + old_memory: string | null; + new_memory: string | null; + user_id: string; + categories: string[]; + event: string; + created_at: string; + updated_at: string; +} + +export function createMockMemoryHistory( + overrides: Partial = {}, +): MockMemoryHistory { + return { + id: "hist_test_123", + memory_id: "mem_test_123", + input: [{ role: "user", content: "test" }], + old_memory: null, + new_memory: "Test memory", + user_id: "user_test", + categories: [], + event: "ADD", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +export interface MockUser { + id: string; + name: string; + created_at: string; + updated_at: string; + total_memories: number; + owner: string; + type: string; +} + +export function createMockUser(overrides: Partial = {}): MockUser { + return { + id: "user_123", + name: "test_user", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + total_memories: 5, + owner: "owner_123", + type: "user", + ...overrides, + }; +} + +export interface MockAllUsers { + count: number; + results: MockUser[]; + next: string | null; + previous: string | null; +} + +export function createMockAllUsers(users: MockUser[] = []): MockAllUsers { + return { + count: users.length, + results: users, + next: null, + previous: null, + }; +} + +// ─── Constants ─────────────────────────────────────────── + +export const TEST_API_KEY = "test-api-key-12345"; +export const TEST_HOST = "https://api.test.mem0.ai"; +export const TEST_ORG_ID = "org_test_123"; +export const TEST_PROJECT_ID = "proj_test_456"; + +export const MOCK_PING_RESPONSE = { + status: "ok", + org_id: TEST_ORG_ID, + project_id: TEST_PROJECT_ID, + user_email: "test@example.com", +}; + +/** + * Creates a standard set of mock responses for common MemoryClient operations. + * Returns a Map that can be extended with additional patterns before passing to createMockFetch. + */ +export function createStandardMockResponses(): Map { + const responses = new Map(); + responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE }); + return responses; +} diff --git a/mem0-ts/src/client/tests/memoryClient.batch.test.ts b/mem0-ts/src/client/tests/memoryClient.batch.test.ts new file mode 100644 index 000000000..79f4ce8d4 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.batch.test.ts @@ -0,0 +1,103 @@ +/** + * MemoryClient unit tests — batchUpdate, batchDelete. + * Tests verify payload transformation (memoryId → memory_id, string → object). + */ +import { MemoryClient } from "../mem0"; +import { TEST_API_KEY } from "./helpers"; +import { + setupMockFetch, + findFetchCall, + getFetchBody, + installConsoleSuppression, +} from "./setup"; + +installConsoleSuppression(); + +// ─── batchUpdate() ────────────────────────────────────── + +describe("MemoryClient - batchUpdate()", () => { + test("sends PUT to /v1/batch/", async () => { + const extra = new Map(); + extra.set("/v1/batch/", { status: 200, body: { message: "OK" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.batchUpdate([{ memoryId: "mem_1", text: "updated 1" }]); + + expect(findFetchCall(mock, "/v1/batch/", "PUT")).toBeDefined(); + }); + + test("transforms memoryId to memory_id in request body", async () => { + const extra = new Map(); + extra.set("/v1/batch/", { status: 200, body: { message: "OK" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.batchUpdate([ + { memoryId: "mem_1", text: "updated 1" }, + { memoryId: "mem_2", text: "updated 2" }, + ]); + + const call = findFetchCall(mock, "/v1/batch/", "PUT"); + const body = getFetchBody(call!); + expect(body.memories).toEqual([ + { memory_id: "mem_1", text: "updated 1" }, + { memory_id: "mem_2", text: "updated 2" }, + ]); + }); + + test("handles empty array without crashing", async () => { + const extra = new Map(); + extra.set("/v1/batch/", { status: 200, body: { message: "OK" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.batchUpdate([]); + + const call = findFetchCall(mock, "/v1/batch/", "PUT"); + expect(getFetchBody(call!).memories).toEqual([]); + }); +}); + +// ─── batchDelete() ────────────────────────────────────── + +describe("MemoryClient - batchDelete()", () => { + test("sends DELETE to /v1/batch/", async () => { + const extra = new Map(); + extra.set("/v1/batch/", { status: 200, body: { message: "OK" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.batchDelete(["mem_1"]); + + expect(findFetchCall(mock, "/v1/batch/", "DELETE")).toBeDefined(); + }); + + test("wraps string IDs into {memory_id} objects", async () => { + const extra = new Map(); + extra.set("/v1/batch/", { status: 200, body: { message: "OK" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.batchDelete(["mem_1", "mem_2", "mem_3"]); + + const call = findFetchCall(mock, "/v1/batch/", "DELETE"); + expect(getFetchBody(call!).memories).toEqual([ + { memory_id: "mem_1" }, + { memory_id: "mem_2" }, + { memory_id: "mem_3" }, + ]); + }); + + test("handles empty array without crashing", async () => { + const extra = new Map(); + extra.set("/v1/batch/", { status: 200, body: { message: "OK" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.batchDelete([]); + + const call = findFetchCall(mock, "/v1/batch/", "DELETE"); + expect(getFetchBody(call!).memories).toEqual([]); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.crud.test.ts b/mem0-ts/src/client/tests/memoryClient.crud.test.ts new file mode 100644 index 000000000..58ada7bbf --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.crud.test.ts @@ -0,0 +1,380 @@ +/** + * MemoryClient unit tests — add, get, getAll, update, delete, deleteAll, history. + * Tests verify request construction, not mock response echo. + */ +import { MemoryClient } from "../mem0"; +import type { Memory, MemoryHistory } from "../mem0.types"; +import { + createMockMemory, + createMockMemoryHistory, + TEST_API_KEY, + TEST_ORG_ID, + TEST_PROJECT_ID, +} from "./helpers"; +import { + setupMockFetch, + findFetchCall, + getFetchBody, + installConsoleSuppression, +} from "./setup"; + +installConsoleSuppression(); + +// ─── add() ─────────────────────────────────────────────── + +describe("MemoryClient - add()", () => { + test("sends POST to /v1/memories/", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.add([{ role: "user", content: "Hello" }], { user_id: "u1" }); + + expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined(); + }); + + test("includes messages in request body", async () => { + const messages = [{ role: "user" as const, content: "Hello, I am Alex" }]; + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.add(messages, { user_id: "u1" }); + + const call = findFetchCall(mock, "/v1/memories/", "POST"); + expect(getFetchBody(call!).messages).toEqual(messages); + }); + + test("includes user_id in request body", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.add([{ role: "user", content: "test" }], { + user_id: "user_1", + }); + + const call = findFetchCall(mock, "/v1/memories/", "POST"); + expect(getFetchBody(call!).user_id).toBe("user_1"); + }); + + test("attaches org_id from constructor to payload", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.add([{ role: "user", content: "test" }], { user_id: "u1" }); + + const call = findFetchCall(mock, "/v1/memories/", "POST"); + const body = getFetchBody(call!); + expect(body.org_id).toBe(TEST_ORG_ID); + }); + + test("attaches project_id from constructor to payload", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.add([{ role: "user", content: "test" }], { user_id: "u1" }); + + const call = findFetchCall(mock, "/v1/memories/", "POST"); + const body = getFetchBody(call!); + expect(body.project_id).toBe(TEST_PROJECT_ID); + }); + + test("sends empty messages array without crashing", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.add([], { user_id: "u1" }); + + const call = findFetchCall(mock, "/v1/memories/", "POST"); + expect(getFetchBody(call!).messages).toEqual([]); + }); +}); + +// ─── get() ─────────────────────────────────────────────── + +describe("MemoryClient - get()", () => { + test("sends GET to /v1/memories/:id/", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/", { + status: 200, + body: createMockMemory(), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.get("mem_123"); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/v1/memories/mem_123/") && !c[1]?.method, + ); + expect(call).toBeDefined(); + }); + + test("throws on 404 with error message from server", async () => { + const extra = new Map(); + extra.set("/v1/memories/nonexistent/", { + status: 404, + body: "Memory not found", + }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.get("nonexistent")).rejects.toThrow("Memory not found"); + }); +}); + +// ─── getAll() ──────────────────────────────────────────── + +describe("MemoryClient - getAll()", () => { + test("uses v2 POST endpoint when api_version=v2", async () => { + const extra = new Map(); + extra.set("/v2/memories/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ user_id: "u1", api_version: "v2" }); + + expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined(); + }); + + test("uses v1 GET endpoint by default with user_id as query param", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ user_id: "u1" }); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/v1/memories/?") && !c[1]?.method, + ); + expect(call).toBeDefined(); + expect(call![0]).toContain("user_id=u1"); + }); + + test("appends page and page_size to URL as query params", async () => { + const extra = new Map(); + extra.set("/v2/memories/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ + user_id: "u1", + api_version: "v2", + page: 2, + page_size: 25, + }); + + const call = mock.mock.calls.find((c: [string, RequestInit]) => + c[0].includes("page="), + ); + expect(call![0]).toContain("page=2"); + expect(call![0]).toContain("page_size=25"); + }); + + test("does not crash when called without options", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: [] }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const result: Memory[] = await client.getAll(); + expect(Array.isArray(result)).toBe(true); + }); +}); + +// ─── update() ──────────────────────────────────────────── + +describe("MemoryClient - update()", () => { + test("sends PUT to /v1/memories/:id/ with text", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/", { + status: 200, + body: createMockMemory(), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.update("mem_123", { text: "Updated text" }); + + const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT"); + expect(call).toBeDefined(); + expect(getFetchBody(call!).text).toBe("Updated text"); + }); + + test("sends metadata in PUT body", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/", { + status: 200, + body: createMockMemory(), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.update("mem_123", { metadata: { priority: "high" } }); + + const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT"); + expect(getFetchBody(call!).metadata).toEqual({ priority: "high" }); + }); + + test("sends timestamp in PUT body", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/", { + status: 200, + body: createMockMemory(), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.update("mem_123", { timestamp: 1710600000 }); + + const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT"); + expect(getFetchBody(call!).timestamp).toBe(1710600000); + }); + + test("includes all fields when text + metadata + timestamp provided", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/", { + status: 200, + body: createMockMemory(), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.update("mem_123", { + text: "Updated", + metadata: { source: "test" }, + timestamp: 1710600000, + }); + + const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT"); + const body = getFetchBody(call!); + expect(body.text).toBe("Updated"); + expect(body.metadata).toEqual({ source: "test" }); + expect(body.timestamp).toBe(1710600000); + }); + + test("throws when no fields provided", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.update("mem_123", {})).rejects.toThrow( + "At least one of text, metadata, or timestamp must be provided", + ); + }); +}); + +// ─── delete() ──────────────────────────────────────────── + +describe("MemoryClient - delete()", () => { + test("sends DELETE to /v1/memories/:id/", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/", { + status: 200, + body: { message: "Memory deleted successfully" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.delete("mem_123"); + + expect( + findFetchCall(mock, "/v1/memories/mem_123/", "DELETE"), + ).toBeDefined(); + }); +}); + +// ─── deleteAll() ───────────────────────────────────────── + +describe("MemoryClient - deleteAll()", () => { + test("sends DELETE to /v1/memories/ with user_id as query param", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.deleteAll({ user_id: "u1" }); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE", + ); + expect(call).toBeDefined(); + expect(call![0]).toContain("user_id=u1"); + }); + + test("URL-encodes special characters in user_id", async () => { + const extra = new Map(); + extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.deleteAll({ user_id: "user@email.com" }); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE", + ); + expect(call).toBeDefined(); + expect(call![0]).toContain("user_id="); + }); +}); + +// ─── history() ─────────────────────────────────────────── + +describe("MemoryClient - history()", () => { + test("sends GET to /v1/memories/:id/history/", async () => { + const historyEntries = [ + createMockMemoryHistory({ + memory_id: "mem_123", + event: "ADD", + old_memory: null, + new_memory: "I am Alex", + }), + ]; + const extra = new Map(); + extra.set("/v1/memories/mem_123/history/", { + status: 200, + body: historyEntries, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.history("mem_123"); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/v1/memories/mem_123/history/") && !c[1]?.method, + ); + expect(call).toBeDefined(); + }); + + test("handles empty history without crashing", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/history/", { status: 200, body: [] }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const result: MemoryHistory[] = await client.history("mem_123"); + expect(result).toEqual([]); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.e2e.test.ts b/mem0-ts/src/client/tests/memoryClient.e2e.test.ts new file mode 100644 index 000000000..99d232a7b --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.e2e.test.ts @@ -0,0 +1,501 @@ +/** + * MemoryClient E2E integration tests. + * + * These tests exercise realistic usage patterns with mock HTTP responses. + * Skipped by default — run with MEM0_RUN_E2E=1 to enable. + * + * Run: MEM0_RUN_E2E=1 npx jest memoryClient.e2e.test.ts + */ +import { MemoryClient } from "../mem0"; +import type { + Memory, + AllUsers, + MemoryHistory, + User, + Messages, +} from "../mem0.types"; +import { + createMockFetch, + createMockMemory, + createMockMemoryHistory, + createMockUser, + createMockAllUsers, + TEST_API_KEY, + MOCK_PING_RESPONSE, +} from "./helpers"; + +const originalFetch = global.fetch; +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; + +beforeAll(() => { + jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + if ( + String(args[0] ?? "").match( + /Telemetry|Failed to initialize|Failed to capture/, + ) + ) + return; + originalConsoleError(...args); + }); + jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + if (String(args[0] ?? "").match(/telemetry|Telemetry/)) return; + originalConsoleWarn(...args); + }); +}); + +afterAll(() => jest.restoreAllMocks()); +afterEach(() => { + global.fetch = originalFetch; +}); + +// Shared test data matching realistic API responses +const userId = "test_user_abc123"; +const memoryId = "mem_550e8400"; + +const mockMemory = createMockMemory({ + id: memoryId, + memory: "Alex is a vegetarian", + user_id: userId, + event: "ADD", + data: { memory: "Alex is a vegetarian" }, + categories: ["personal"], + metadata: null, + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + score: 0.95, +}); + +function mockFetchForTest( + extraPatterns?: Record, +) { + const responses = new Map(); + responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE }); + responses.set("/v1/memories/search/", { status: 200, body: [mockMemory] }); + responses.set("/v2/memories/search/", { status: 200, body: [mockMemory] }); + responses.set("/history/", { + status: 200, + body: [ + createMockMemoryHistory({ + memory_id: memoryId, + user_id: userId, + event: "ADD", + old_memory: null, + new_memory: "Alex is a vegetarian", + }), + ], + }); + responses.set("/v1/entities/", { + status: 200, + body: createMockAllUsers([ + createMockUser({ id: "entity_1", name: userId, type: "user" }), + ]), + }); + // This must come last — it's a broad pattern that matches /v1/memories/:id/ and /v1/memories/ + responses.set("/v1/memories/", { status: 200, body: [mockMemory] }); + + if (extraPatterns) { + for (const [k, v] of Object.entries(extraPatterns)) { + responses.set(k, v); + } + } + + global.fetch = createMockFetch(responses); +} + +const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip; + +describeOrSkip("MemoryClient API (E2E)", () => { + beforeEach(() => mockFetchForTest()); + + const messages1 = [ + { role: "user" as const, content: "Hey, I am Alex. I'm now a vegetarian." }, + { role: "assistant" as const, content: "Hello Alex! Glad to hear!" }, + ]; + + describe("add messages", () => { + let res: Memory[]; + + beforeEach(async () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + res = await client.add(messages1, { user_id: userId }); + }); + + test("returns an array", () => { + expect(Array.isArray(res)).toBe(true); + }); + + test("first message has a string id", () => { + expect(typeof res[0].id).toBe("string"); + }); + + test("first message has a string data.memory", () => { + expect(typeof res[0].data?.memory).toBe("string"); + }); + + test("first message has a string event", () => { + expect(typeof res[0].event).toBe("string"); + }); + }); + + describe("retrieve specific memory by ID", () => { + let memory: Memory; + + beforeEach(async () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const memories = await client.getAll({ user_id: userId }); + memory = Array.isArray(memories) ? memories[0] : memories; + }); + + test("returns string id", () => { + expect(typeof memory.id).toBe("string"); + }); + + test("returns string memory content", () => { + expect(typeof memory.memory).toBe("string"); + }); + + test("returns string user_id", () => { + expect(typeof memory.user_id).toBe("string"); + }); + + test("user_id matches the requested userId", () => { + expect(memory.user_id).toBe(userId); + }); + + test("metadata is null or an object", () => { + expect( + memory.metadata === null || typeof memory.metadata === "object", + ).toBe(true); + }); + + test("categories is an array or null", () => { + expect( + Array.isArray(memory.categories) || memory.categories === null, + ).toBe(true); + }); + + test("each category is a string", () => { + if (Array.isArray(memory.categories)) { + expect( + memory.categories.every((c: string) => typeof c === "string"), + ).toBe(true); + } + }); + + test("created_at is a valid date", () => { + expect(new Date(memory.created_at || "").toString()).not.toBe( + "Invalid Date", + ); + }); + + test("updated_at is a valid date", () => { + expect(new Date(memory.updated_at || "").toString()).not.toBe( + "Invalid Date", + ); + }); + }); + + describe("retrieve all users", () => { + let allUsers: AllUsers; + + beforeEach(async () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + allUsers = await client.users(); + }); + + test("count is a number", () => { + expect(typeof allUsers.count).toBe("number"); + }); + + test("first user has a string id", () => { + expect(typeof allUsers.results[0].id).toBe("string"); + }); + + test("first user has a string name", () => { + expect(typeof allUsers.results[0].name).toBe("string"); + }); + + test("first user has a string created_at", () => { + expect(typeof allUsers.results[0].created_at).toBe("string"); + }); + + test("first user has a string updated_at", () => { + expect(typeof allUsers.results[0].updated_at).toBe("string"); + }); + + test("first user has a number total_memories", () => { + expect(typeof allUsers.results[0].total_memories).toBe("number"); + }); + + test("first user has a string type", () => { + expect(typeof allUsers.results[0].type).toBe("string"); + }); + + test("results contain an entity matching userId", () => { + const entity = allUsers.results.find( + (user: User) => user.name === userId, + ); + expect(entity).not.toBeUndefined(); + }); + + test("matched entity has a string id", () => { + const entity = allUsers.results.find( + (user: User) => user.name === userId, + ); + expect(typeof entity?.id).toBe("string"); + }); + }); + + describe("retrieve all memories for the user", () => { + let memories: Memory[]; + let memory: Memory; + + beforeEach(async () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + memories = await client.getAll({ user_id: userId }); + memory = memories[0]; + }); + + test("returns an array", () => { + expect(Array.isArray(memories)).toBe(true); + }); + + test("first memory has a string id", () => { + expect(typeof memory.id).toBe("string"); + }); + + test("first memory has a string memory content", () => { + expect(typeof memory.memory).toBe("string"); + }); + + test("first memory has a string user_id", () => { + expect(typeof memory.user_id).toBe("string"); + }); + + test("first memory user_id matches the requested userId", () => { + expect(memory.user_id).toBe(userId); + }); + + test("first memory metadata is null or an object", () => { + expect( + memory.metadata === null || typeof memory.metadata === "object", + ).toBe(true); + }); + + test("first memory categories is an array or null", () => { + expect( + Array.isArray(memory.categories) || memory.categories === null, + ).toBe(true); + }); + + test("first memory created_at is a valid date", () => { + expect(new Date(memory.created_at || "").toString()).not.toBe( + "Invalid Date", + ); + }); + + test("first memory updated_at is a valid date", () => { + expect(new Date(memory.updated_at || "").toString()).not.toBe( + "Invalid Date", + ); + }); + }); + + describe("search with API version 2", () => { + let results: Memory[]; + let memory: Memory; + + beforeEach(async () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + results = await client.search("What do you know about me?", { + filters: { + OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }], + }, + threshold: 0.1, + api_version: "v2", + }); + memory = results[0]; + }); + + test("returns an array", () => { + expect(Array.isArray(results)).toBe(true); + }); + + test("first result has a string id", () => { + expect(typeof memory.id).toBe("string"); + }); + + test("first result has a string memory content", () => { + expect(typeof memory.memory).toBe("string"); + }); + + test("first result metadata is null or an object", () => { + expect( + memory.metadata === null || typeof memory.metadata === "object", + ).toBe(true); + }); + + test("first result categories is an array or null", () => { + expect( + Array.isArray(memory.categories) || memory.categories === null, + ).toBe(true); + }); + + test("first result created_at is a valid date", () => { + expect(new Date(memory.created_at || "").toString()).not.toBe( + "Invalid Date", + ); + }); + + test("first result has a number score", () => { + expect(typeof memory.score).toBe("number"); + }); + }); + + describe("search with API version 1", () => { + let results: Memory[]; + let memory: Memory; + + beforeEach(async () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + results = await client.search("What is my name?", { + user_id: userId, + }); + memory = results[0]; + }); + + test("returns an array", () => { + expect(Array.isArray(results)).toBe(true); + }); + + test("first result has a string id", () => { + expect(typeof memory.id).toBe("string"); + }); + + test("first result has a string memory content", () => { + expect(typeof memory.memory).toBe("string"); + }); + + test("first result has a string user_id", () => { + expect(typeof memory.user_id).toBe("string"); + }); + + test("first result user_id matches the requested userId", () => { + expect(memory.user_id).toBe(userId); + }); + + test("first result has a number score", () => { + expect(typeof memory.score).toBe("number"); + }); + }); + + describe("retrieve history of a specific memory", () => { + let history: MemoryHistory[]; + let entry: MemoryHistory; + + beforeEach(async () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + history = await client.history(memoryId); + entry = history[0]; + }); + + test("returns an array", () => { + expect(Array.isArray(history)).toBe(true); + }); + + test("first entry has a string id", () => { + expect(typeof entry.id).toBe("string"); + }); + + test("first entry has a string memory_id", () => { + expect(typeof entry.memory_id).toBe("string"); + }); + + test("first entry has a string user_id", () => { + expect(typeof entry.user_id).toBe("string"); + }); + + test("first entry user_id matches the requested userId", () => { + expect(entry.user_id).toBe(userId); + }); + + test("old_memory is null or a string", () => { + expect( + entry.old_memory === null || typeof entry.old_memory === "string", + ).toBe(true); + }); + + test("new_memory is null or a string", () => { + expect( + entry.new_memory === null || typeof entry.new_memory === "string", + ).toBe(true); + }); + + test("created_at is a valid date", () => { + expect(new Date(entry.created_at).toString()).not.toBe("Invalid Date"); + }); + + test("updated_at is a valid date", () => { + expect(new Date(entry.updated_at).toString()).not.toBe("Invalid Date"); + }); + + test("event is one of ADD, UPDATE, DELETE, NOOP", () => { + expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(entry.event); + }); + + test("ADD event has null old_memory", () => { + expect(entry.old_memory).toBeNull(); + }); + + test("ADD event has non-null new_memory", () => { + expect(entry.new_memory).not.toBeNull(); + }); + + test("input is an array or null", () => { + expect(Array.isArray(entry.input) || entry.input === null).toBe(true); + }); + + test("each input item is an object", () => { + if (Array.isArray(entry.input)) { + expect(entry.input.every((i: Messages) => typeof i === "object")).toBe( + true, + ); + } + }); + + test("each input item has a string content", () => { + if (Array.isArray(entry.input)) { + expect( + entry.input.every((i: Messages) => typeof i.content === "string"), + ).toBe(true); + } + }); + + test("each input item has a valid role", () => { + if (Array.isArray(entry.input)) { + expect( + entry.input.every((i: Messages) => + ["user", "assistant"].includes(i.role), + ), + ).toBe(true); + } + }); + }); + + describe("delete user", () => { + test("returns success message", async () => { + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: "org_test", + projectId: "proj_test", + }); + client.client.delete = jest.fn().mockResolvedValue({ + data: { message: "Entity deleted successfully!" }, + }); + + const result = await client.deleteUsers({ user_id: userId }); + expect(result.message).toBe("Entity deleted successfully."); + }); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.init.test.ts b/mem0-ts/src/client/tests/memoryClient.init.test.ts new file mode 100644 index 000000000..f31e89cdb --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.init.test.ts @@ -0,0 +1,251 @@ +/** + * MemoryClient unit tests — constructor, validation, ping. + */ +import { MemoryClient } from "../mem0"; +import { + MemoryNotFoundError, + ValidationError, + MemoryError, +} from "../../common/exceptions"; +import { + createMockFetch, + TEST_API_KEY, + TEST_HOST, + TEST_ORG_ID, + TEST_PROJECT_ID, +} from "./helpers"; +import { + setupMockFetch, + installConsoleSuppression, + MOCK_PING_RESPONSE, +} from "./setup"; + +installConsoleSuppression(); + +// ─── Initialization ────────────────────────────────────── + +describe("MemoryClient - Initialization", () => { + beforeEach(() => setupMockFetch()); + + test("throws when API key is empty string", () => { + expect(() => new MemoryClient({ apiKey: "" })).toThrow( + "Mem0 API key is required", + ); + }); + + test("throws when API key is whitespace only", () => { + expect(() => new MemoryClient({ apiKey: " " })).toThrow( + "Mem0 API key cannot be empty", + ); + }); + + test("throws when API key is not a string", () => { + expect( + () => new MemoryClient({ apiKey: 123 as unknown as string }), + ).toThrow("Mem0 API key must be a string"); + }); + + test("sets default host to https://api.mem0.ai", () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + expect(client.host).toBe("https://api.mem0.ai"); + }); + + test("uses custom host when provided", () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY, host: TEST_HOST }); + expect(client.host).toBe(TEST_HOST); + }); + + test("sets organizationId from constructor", () => { + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + expect(client.organizationId).toBe(TEST_ORG_ID); + }); + + test("sets projectId from constructor", () => { + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + expect(client.projectId).toBe(TEST_PROJECT_ID); + }); + + test("sets Authorization header with Token prefix", () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + expect(client.headers["Authorization"]).toBe(`Token ${TEST_API_KEY}`); + }); + + test("creates axios client with 60s timeout", () => { + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + expect(client.client.defaults.timeout).toBe(60000); + }); +}); + +// ─── Ping ──────────────────────────────────────────────── + +describe("MemoryClient - ping()", () => { + test("sets organizationId from ping response", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.ping(); + expect(client.organizationId).toBe(TEST_ORG_ID); + }); + + test("sets projectId from ping response", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.ping(); + expect(client.projectId).toBe(TEST_PROJECT_ID); + }); + + test("sets telemetryId from user_email in response", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.ping(); + expect(client.telemetryId).toBe("test@example.com"); + }); + + test("preserves constructor organizationId over ping response", async () => { + setupMockFetch(); + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: "my_org", + projectId: "my_proj", + }); + await client.ping(); + expect(client.organizationId).toBe("my_org"); + }); + + test("preserves constructor projectId over ping response", async () => { + setupMockFetch(); + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: "my_org", + projectId: "my_proj", + }); + await client.ping(); + expect(client.projectId).toBe("my_proj"); + }); + + test("throws AuthenticationError on 401 response", async () => { + const { AuthenticationError } = await import("../../common/exceptions"); + const responses = new Map(); + responses.set("/v1/ping/", { + status: 401, + body: "Invalid API key", + }); + global.fetch = createMockFetch(responses); + + const client = new MemoryClient({ apiKey: "bad-key" }); + await expect(client.ping()).rejects.toThrow(AuthenticationError); + }); + + test("throws on invalid (non-object) response format", async () => { + const responses = new Map(); + responses.set("/v1/ping/", { status: 200, body: "not an object" }); + global.fetch = createMockFetch(responses); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.ping()).rejects.toThrow("Invalid response format"); + }); + + test("throws on status !== ok in response", async () => { + const responses = new Map(); + responses.set("/v1/ping/", { + status: 200, + body: { status: "error", message: "API Key is invalid" }, + }); + global.fetch = createMockFetch(responses); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.ping()).rejects.toThrow("API Key is invalid"); + }); +}); + +// ─── Error Handling ────────────────────────────────────── + +describe("MemoryClient - Error Handling", () => { + test("404 throws MemoryNotFoundError with server response text", async () => { + const extra = new Map(); + extra.set("/v1/memories/gone/", { status: 404, body: "Memory not found" }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.get("gone")).rejects.toThrow(MemoryNotFoundError); + await expect(client.get("gone")).rejects.toThrow("Memory not found"); + }); + + test("500 throws MemoryError with server response text", async () => { + const extra = new Map(); + extra.set("/v1/memories/err/", { + status: 500, + body: "Internal server error", + }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.get("err")).rejects.toThrow(MemoryError); + await expect(client.get("err")).rejects.toThrow("Internal server error"); + }); + + test("400 throws ValidationError with details from server", async () => { + const extra = new Map(); + extra.set("/v1/memories/bad/", { + status: 400, + body: "Invalid request: user_id is required", + }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.get("bad")).rejects.toThrow(ValidationError); + await expect(client.get("bad")).rejects.toThrow( + "Invalid request: user_id is required", + ); + }); + + test("Authorization header is included in fetch calls", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_1/", { + status: 200, + body: { id: "mem_1" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.get("mem_1"); + + const call = mock.mock.calls.find((c: [string, RequestInit]) => + c[0].includes("/v1/memories/mem_1/"), + ); + const headers = call![1].headers as Record; + expect(headers["Authorization"]).toContain(TEST_API_KEY); + }); + + test("network failure (fetch throws) is propagated", async () => { + global.fetch = jest.fn(async (url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.includes("/v1/memories/net_err/")) { + throw new TypeError("Failed to fetch"); + } + if (urlStr.includes("/v1/ping/")) { + return { + ok: true, + status: 200, + json: async () => MOCK_PING_RESPONSE, + text: async () => JSON.stringify(MOCK_PING_RESPONSE), + } as Response; + } + return { + ok: false, + status: 404, + text: async () => "Not found", + } as Response; + }); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.get("net_err")).rejects.toThrow(); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.project.test.ts b/mem0-ts/src/client/tests/memoryClient.project.test.ts new file mode 100644 index 000000000..01f02c760 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.project.test.ts @@ -0,0 +1,251 @@ +/** + * MemoryClient unit tests — getProject, updateProject, exports, feedback. + * Tests verify request construction and validation behavior. + */ +import { MemoryClient } from "../mem0"; +import { Feedback } from "../mem0.types"; +import { + createMockFetch, + TEST_API_KEY, + TEST_ORG_ID, + TEST_PROJECT_ID, +} from "./helpers"; +import { + setupMockFetch, + findFetchCall, + getFetchBody, + installConsoleSuppression, +} from "./setup"; + +installConsoleSuppression(); + +// ─── getProject() ─────────────────────────────────────── + +describe("MemoryClient - getProject()", () => { + test("throws when organizationId and projectId not set", async () => { + const responses = new Map(); + responses.set("/v1/ping/", { status: 200, body: { status: "ok" } }); + global.fetch = createMockFetch(responses); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + try { + await client.ping(); + } catch { + // ping might throw — but orgId stays null + } + + await expect( + client.getProject({ fields: ["custom_instructions"] }), + ).rejects.toThrow("organizationId and projectId must be set"); + }); + + test("sends GET to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { custom_instructions: "Be helpful" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.getProject({ fields: ["custom_instructions"] }); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/api/v1/orgs/organizations/") && !c[1]?.method, + ); + expect(call).toBeDefined(); + expect(call![0]).toContain("fields=custom_instructions"); + }); +}); + +// ─── updateProject() ──────────────────────────────────── + +describe("MemoryClient - updateProject()", () => { + test("sends PATCH to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { custom_instructions: "Updated" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.updateProject({ + custom_instructions: "Updated instructions", + }); + + const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH"); + expect(call).toBeDefined(); + }); + + test("includes custom_instructions in PATCH body", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { custom_instructions: "Updated" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.updateProject({ + custom_instructions: "Updated instructions", + }); + + const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH"); + expect(getFetchBody(call!).custom_instructions).toBe( + "Updated instructions", + ); + }); +}); + +// ─── feedback() ───────────────────────────────────────── + +describe("MemoryClient - feedback()", () => { + test("sends POST to /v1/feedback/ with payload", async () => { + const extra = new Map(); + extra.set("/v1/feedback/", { + status: 200, + body: { message: "Feedback recorded" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.feedback({ + memory_id: "mem_123", + feedback: Feedback.POSITIVE, + feedback_reason: "Very helpful", + }); + + const call = findFetchCall(mock, "/v1/feedback/", "POST"); + expect(call).toBeDefined(); + }); + + test("includes memory_id, feedback, and reason in body", async () => { + const extra = new Map(); + extra.set("/v1/feedback/", { + status: 200, + body: { message: "Feedback recorded" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.feedback({ + memory_id: "mem_123", + feedback: Feedback.POSITIVE, + feedback_reason: "Very helpful", + }); + + const call = findFetchCall(mock, "/v1/feedback/", "POST"); + const body = getFetchBody(call!); + expect(body.memory_id).toBe("mem_123"); + expect(body.feedback).toBe("POSITIVE"); + expect(body.feedback_reason).toBe("Very helpful"); + }); +}); + +// ─── Memory Exports ───────────────────────────────────── + +describe("MemoryClient - Memory Exports", () => { + test("createMemoryExport throws when missing filters or schema", async () => { + setupMockFetch(); + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await expect( + client.createMemoryExport({ + filters: null as never, + schema: null as never, + }), + ).rejects.toThrow("Missing filters or schema"); + }); + + test("createMemoryExport sends POST to /v1/exports/", async () => { + const extra = new Map(); + extra.set("/v1/exports/", { + status: 200, + body: { message: "Export created", id: "exp_123" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.createMemoryExport({ + schema: { fields: ["memory", "user_id"] }, + filters: { user_id: "u1" }, + }); + + expect(findFetchCall(mock, "/v1/exports/", "POST")).toBeDefined(); + }); + + test("createMemoryExport attaches org_id and project_id to body", async () => { + const extra = new Map(); + extra.set("/v1/exports/", { + status: 200, + body: { message: "Created", id: "exp_1" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.createMemoryExport({ + schema: { fields: ["memory"] }, + filters: { user_id: "u1" }, + }); + + const call = findFetchCall(mock, "/v1/exports/", "POST"); + const body = getFetchBody(call!); + expect(body.org_id).toBe(TEST_ORG_ID); + expect(body.project_id).toBe(TEST_PROJECT_ID); + }); + + test("getMemoryExport throws when missing both id and filters", async () => { + setupMockFetch(); + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await expect(client.getMemoryExport({} as never)).rejects.toThrow( + "Missing memory_export_id or filters", + ); + }); + + test("getMemoryExport sends POST to /v1/exports/get/", async () => { + const extra = new Map(); + extra.set("/v1/exports/get/", { + status: 200, + body: { message: "Export data", id: "exp_123" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + await client.getMemoryExport({ memory_export_id: "exp_123" }); + + expect(findFetchCall(mock, "/v1/exports/get/", "POST")).toBeDefined(); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.search.test.ts b/mem0-ts/src/client/tests/memoryClient.search.test.ts new file mode 100644 index 000000000..b7c97595f --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.search.test.ts @@ -0,0 +1,103 @@ +/** + * MemoryClient unit tests — search (v1/v2 routing, filters). + * Tests verify request construction, not mock response echo. + */ +import { MemoryClient } from "../mem0"; +import type { Memory } from "../mem0.types"; +import { createMockMemory, TEST_API_KEY } from "./helpers"; +import { + setupMockFetch, + findFetchCall, + getFetchBody, + installConsoleSuppression, +} from "./setup"; + +installConsoleSuppression(); + +describe("MemoryClient - search()", () => { + test("sends POST to /v1/memories/search/ by default", async () => { + const extra = new Map(); + extra.set("/v1/memories/search/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("What is my name?", { user_id: "u1" }); + + expect(findFetchCall(mock, "/v1/memories/search/", "POST")).toBeDefined(); + }); + + test("includes query in request body", async () => { + const extra = new Map(); + extra.set("/v1/memories/search/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("What is my name?", { user_id: "u1" }); + + const call = findFetchCall(mock, "/v1/memories/search/", "POST"); + expect(getFetchBody(call!).query).toBe("What is my name?"); + }); + + test("includes user_id in request body", async () => { + const extra = new Map(); + extra.set("/v1/memories/search/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { user_id: "u1" }); + + const call = findFetchCall(mock, "/v1/memories/search/", "POST"); + expect(getFetchBody(call!).user_id).toBe("u1"); + }); + + test("uses /v2/memories/search/ when api_version=v2", async () => { + const extra = new Map(); + extra.set("/v2/memories/search/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { user_id: "u1", api_version: "v2" }); + + expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined(); + }); + + test("passes filters through to the v2 API body", async () => { + const extra = new Map(); + extra.set("/v2/memories/search/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("query", { + api_version: "v2", + filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] }, + }); + + const call = findFetchCall(mock, "/v2/memories/search/", "POST"); + const body = getFetchBody(call!); + expect(body.filters).toEqual({ + OR: [{ user_id: "u1" }, { agent_id: "a1" }], + }); + }); + + test("does not crash when called without options", async () => { + const extra = new Map(); + extra.set("/v1/memories/search/", { status: 200, body: [] }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const result: Memory[] = await client.search("query"); + expect(Array.isArray(result)).toBe(true); + }); + + test("handles empty results array", async () => { + const extra = new Map(); + extra.set("/v1/memories/search/", { status: 200, body: [] }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const result: Memory[] = await client.search("nonexistent query", { + user_id: "u1", + }); + expect(result).toHaveLength(0); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.test.ts b/mem0-ts/src/client/tests/memoryClient.test.ts deleted file mode 100644 index 2d6df6eb4..000000000 --- a/mem0-ts/src/client/tests/memoryClient.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { MemoryClient } from "../mem0"; -import dotenv from "dotenv"; - -dotenv.config(); - -const apiKey = process.env.MEM0_API_KEY || ""; -// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationId: "org_gRNd1RrQa4y52iK4tG8o59hXyVbaULikgq4kethC", projectId: "proj_7RfMkWs0PMgXYweGUNKqV9M9mgIRNt5XcupE7mSP" }); -// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationName: "saket-default-org", projectName: "default-project" }); -const client = new MemoryClient({ apiKey, host: "https://api.mem0.ai" }); - -// Generate a random string -const randomString = () => { - return ( - Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15) - ); -}; - -describe("MemoryClient API", () => { - let userId: string, memoryId: string; - - beforeAll(() => { - userId = randomString(); - }); - - const messages1 = [ - { role: "user", content: "Hey, I am Alex. I'm now a vegetarian." }, - { role: "assistant", content: "Hello Alex! Glad to hear!" }, - ]; - - it("should add messages successfully", async () => { - const res = await client.add(messages1, { user_id: userId || "" }); - - // Validate the response contains an iterable list - expect(Array.isArray(res)).toBe(true); - - // Validate the fields of the first message in the response - const message = res[0]; - expect(typeof message.id).toBe("string"); - expect(typeof message.data?.memory).toBe("string"); - expect(typeof message.event).toBe("string"); - - // Store the memory ID for later use - memoryId = message.id; - }); - - it("should retrieve the specific memory by ID", async () => { - const memory = await client.get(memoryId); - - // Validate that the memory fields have the correct types and values - - // Should be a string (memory id) - expect(typeof memory.id).toBe("string"); - - // Should be a string (the actual memory content) - expect(typeof memory.memory).toBe("string"); - - // Should be a string and equal to the userId - expect(typeof memory.user_id).toBe("string"); - expect(memory.user_id).toBe(userId); - - // Should be null or any object (metadata) - expect( - memory.metadata === null || typeof memory.metadata === "object", - ).toBe(true); - - // Should be an array of strings or null (categories) - expect(Array.isArray(memory.categories) || memory.categories === null).toBe( - true, - ); - if (Array.isArray(memory.categories)) { - memory.categories.forEach((category) => { - expect(typeof category).toBe("string"); - }); - } - - // Should be a valid date (created_at) - expect(new Date(memory.created_at || "").toString()).not.toBe( - "Invalid Date", - ); - - // Should be a valid date (updated_at) - expect(new Date(memory.updated_at || "").toString()).not.toBe( - "Invalid Date", - ); - }); - - it("should retrieve all users successfully", async () => { - const allUsers = await client.users(); - - // Validate the number of users is a number - expect(typeof allUsers.count).toBe("number"); - - // Validate the structure of the first user - const firstUser = allUsers.results[0]; - expect(typeof firstUser.id).toBe("string"); - expect(typeof firstUser.name).toBe("string"); - expect(typeof firstUser.created_at).toBe("string"); - expect(typeof firstUser.updated_at).toBe("string"); - expect(typeof firstUser.total_memories).toBe("number"); - expect(typeof firstUser.type).toBe("string"); - - // Find the user with the name matching userId - const entity = allUsers.results.find((user) => user.name === userId); - expect(entity).not.toBeUndefined(); - - // Store the entity ID for later use - const entity_id = entity?.id; - expect(typeof entity_id).toBe("string"); - }); - - it("should retrieve all memories for the user", async () => { - const res3 = await client.getAll({ user_id: userId }); - - // Validate that res3 is an iterable list (array) - expect(Array.isArray(res3)).toBe(true); - - if (res3.length > 0) { - // Iterate through the first memory for validation (you can loop through all if needed) - const memory = res3[0]; - - // Should be a string (memory id) - expect(typeof memory.id).toBe("string"); - - // Should be a string (the actual memory content) - expect(typeof memory.memory).toBe("string"); - - // Should be a string and equal to the userId - expect(typeof memory.user_id).toBe("string"); - expect(memory.user_id).toBe(userId); - - // Should be null or an object (metadata) - expect( - memory.metadata === null || typeof memory.metadata === "object", - ).toBe(true); - - // Should be an array of strings or null (categories) - expect( - Array.isArray(memory.categories) || memory.categories === null, - ).toBe(true); - if (Array.isArray(memory.categories)) { - memory.categories.forEach((category) => { - expect(typeof category).toBe("string"); - }); - } - - // Should be a valid date (created_at) - expect(new Date(memory.created_at || "").toString()).not.toBe( - "Invalid Date", - ); - - // Should be a valid date (updated_at) - expect(new Date(memory.updated_at || "").toString()).not.toBe( - "Invalid Date", - ); - } else { - // If there are no memories, assert that the list is empty - expect(res3.length).toBe(0); - } - }); - - it("should search and return results based on provided query and filters (API version 2)", async () => { - const searchOptionsV2 = { - query: "What do you know about me?", - filters: { - OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }], - }, - threshold: 0.1, - api_version: "v2", - }; - - const searchResultV2 = await client.search( - "What do you know about me?", - searchOptionsV2, - ); - - // Validate that searchResultV2 is an iterable list (array) - expect(Array.isArray(searchResultV2)).toBe(true); - - if (searchResultV2.length > 0) { - // Iterate through the first search result for validation (you can loop through all if needed) - const memory = searchResultV2[0]; - - // Should be a string (memory id) - expect(typeof memory.id).toBe("string"); - - // Should be a string (the actual memory content) - expect(typeof memory.memory).toBe("string"); - - if (memory.user_id) { - // Should be a string and equal to userId - expect(typeof memory.user_id).toBe("string"); - expect(memory.user_id).toBe(userId); - } - - if (memory.agent_id) { - // Should be a string (agent_id) - expect(typeof memory.agent_id).toBe("string"); - expect(memory.agent_id).toBe("shopping-assistant"); - } - - // Should be null or an object (metadata) - expect( - memory.metadata === null || typeof memory.metadata === "object", - ).toBe(true); - - // Should be an array of strings or null (categories) - expect( - Array.isArray(memory.categories) || memory.categories === null, - ).toBe(true); - if (Array.isArray(memory.categories)) { - memory.categories.forEach((category) => { - expect(typeof category).toBe("string"); - }); - } - - // Should be a valid date (created_at) - expect(new Date(memory.created_at || "").toString()).not.toBe( - "Invalid Date", - ); - - // Should be a valid date (updated_at) - expect(new Date(memory.updated_at || "").toString()).not.toBe( - "Invalid Date", - ); - - // Should be a number (score) - expect(typeof memory.score).toBe("number"); - } else { - // If no search results, assert that the list is empty - expect(searchResultV2.length).toBe(0); - } - }); - - it("should search and return results based on provided query (API version 1)", async () => { - const searchResultV1 = await client.search("What is my name?", { - user_id: userId, - }); - - // Validate that searchResultV1 is an iterable list (array) - expect(Array.isArray(searchResultV1)).toBe(true); - - if (searchResultV1.length > 0) { - // Iterate through the first search result for validation (you can loop through all if needed) - const memory = searchResultV1[0]; - - // Should be a string (memory id) - expect(typeof memory.id).toBe("string"); - - // Should be a string (the actual memory content) - expect(typeof memory.memory).toBe("string"); - - // Should be a string and equal to userId - expect(typeof memory.user_id).toBe("string"); - expect(memory.user_id).toBe(userId); - - // Should be null or an object (metadata) - expect( - memory.metadata === null || typeof memory.metadata === "object", - ).toBe(true); - - // Should be an array of strings or null (categories) - expect( - Array.isArray(memory.categories) || memory.categories === null, - ).toBe(true); - if (Array.isArray(memory.categories)) { - memory.categories.forEach((category) => { - expect(typeof category).toBe("string"); - }); - } - - // Should be a valid date (created_at) - expect(new Date(memory.created_at || "").toString()).not.toBe( - "Invalid Date", - ); - - // Should be a valid date (updated_at) - expect(new Date(memory.updated_at || "").toString()).not.toBe( - "Invalid Date", - ); - - // Should be a number (score) - expect(typeof memory.score).toBe("number"); - } else { - // If no search results, assert that the list is empty - expect(searchResultV1.length).toBe(0); - } - }); - - it("should retrieve history of a specific memory and validate the fields", async () => { - const res22 = await client.history(memoryId); - - // Validate that res22 is an iterable list (array) - expect(Array.isArray(res22)).toBe(true); - - if (res22.length > 0) { - // Iterate through the first history entry for validation (you can loop through all if needed) - const historyEntry = res22[0]; - - // Should be a string (history entry id) - expect(typeof historyEntry.id).toBe("string"); - - // Should be a string (memory id related to the history entry) - expect(typeof historyEntry.memory_id).toBe("string"); - - // Should be a string and equal to userId - expect(typeof historyEntry.user_id).toBe("string"); - expect(historyEntry.user_id).toBe(userId); - - // Should be a string or null (old memory) - expect( - historyEntry.old_memory === null || - typeof historyEntry.old_memory === "string", - ).toBe(true); - - // Should be a string or null (new memory) - expect( - historyEntry.new_memory === null || - typeof historyEntry.new_memory === "string", - ).toBe(true); - - // Should be an array of strings or null (categories) - expect( - Array.isArray(historyEntry.categories) || - historyEntry.categories === null, - ).toBe(true); - if (Array.isArray(historyEntry.categories)) { - historyEntry.categories.forEach((category) => { - expect(typeof category).toBe("string"); - }); - } - - // Should be a valid date (created_at) - expect(new Date(historyEntry.created_at).toString()).not.toBe( - "Invalid Date", - ); - - // Should be a valid date (updated_at) - expect(new Date(historyEntry.updated_at).toString()).not.toBe( - "Invalid Date", - ); - - // Should be a string, one of: ADD, UPDATE, DELETE, NOOP - expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(historyEntry.event); - - // Validate conditions based on event type - if (historyEntry.event === "ADD") { - expect(historyEntry.old_memory).toBeNull(); - expect(historyEntry.new_memory).not.toBeNull(); - } else if (historyEntry.event === "UPDATE") { - expect(historyEntry.old_memory).not.toBeNull(); - expect(historyEntry.new_memory).not.toBeNull(); - } else if (historyEntry.event === "DELETE") { - expect(historyEntry.old_memory).not.toBeNull(); - expect(historyEntry.new_memory).toBeNull(); - } - - // Should be a list of objects or null (input) - expect( - Array.isArray(historyEntry.input) || historyEntry.input === null, - ).toBe(true); - if (Array.isArray(historyEntry.input)) { - historyEntry.input.forEach((input) => { - // Each input should be an object - expect(typeof input).toBe("object"); - - // Should have string content - expect(typeof input.content).toBe("string"); - - // Should have a role that is either 'user' or 'assistant' - expect(["user", "assistant"]).toContain(input.role); - }); - } - } else { - // If no history entries, assert that the list is empty - expect(res22.length).toBe(0); - } - }); - - it("should delete the user successfully", async () => { - const allUsers = await client.users(); - const entity = allUsers.results.find((user) => user.name === userId); - - if (entity) { - const deletedUser = await client.deleteUser(entity.id); - - // Validate the deletion message - expect(deletedUser.message).toBe("Entity deleted successfully!"); - } - }); -}); diff --git a/mem0-ts/src/client/tests/memoryClient.users.test.ts b/mem0-ts/src/client/tests/memoryClient.users.test.ts new file mode 100644 index 000000000..f4f6d5688 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.users.test.ts @@ -0,0 +1,185 @@ +/** + * MemoryClient unit tests — users, deleteUser, deleteUsers. + * Tests verify entity type routing and request construction. + */ +import { MemoryClient } from "../mem0"; +import { + createMockUser, + createMockAllUsers, + TEST_API_KEY, + TEST_ORG_ID, + TEST_PROJECT_ID, +} from "./helpers"; +import { + setupMockFetch, + findFetchCall, + installConsoleSuppression, +} from "./setup"; + +installConsoleSuppression(); + +// ─── users() ──────────────────────────────────────────── + +describe("MemoryClient - users()", () => { + test("sends GET to /v1/entities/", async () => { + const extra = new Map(); + extra.set("/v1/entities/", { + status: 200, + body: createMockAllUsers([createMockUser()]), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.users(); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/v1/entities/") && !c[1]?.method, + ); + expect(call).toBeDefined(); + }); +}); + +// ─── deleteUsers() ────────────────────────────────────── + +describe("MemoryClient - deleteUsers()", () => { + function createClientWithMockedAxios() { + setupMockFetch(); + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + const axiosDeleteMock = jest + .fn() + .mockResolvedValue({ data: { message: "Deleted" } }); + client.client.delete = axiosDeleteMock; + return { client, axiosDeleteMock }; + } + + test("routes user_id to DELETE /v2/entities/user/:name/", async () => { + const { client, axiosDeleteMock } = createClientWithMockedAxios(); + await client.deleteUsers({ user_id: "u1" }); + + expect(axiosDeleteMock).toHaveBeenCalledWith("/v2/entities/user/u1/", { + params: expect.objectContaining({ + org_id: TEST_ORG_ID, + project_id: TEST_PROJECT_ID, + }), + }); + }); + + test("routes agent_id to DELETE /v2/entities/agent/:name/", async () => { + const { client, axiosDeleteMock } = createClientWithMockedAxios(); + await client.deleteUsers({ agent_id: "agent_1" }); + + expect(axiosDeleteMock).toHaveBeenCalledWith( + "/v2/entities/agent/agent_1/", + expect.any(Object), + ); + }); + + test("routes app_id to DELETE /v2/entities/app/:name/", async () => { + const { client, axiosDeleteMock } = createClientWithMockedAxios(); + await client.deleteUsers({ app_id: "app_1" }); + + expect(axiosDeleteMock).toHaveBeenCalledWith( + "/v2/entities/app/app_1/", + expect.any(Object), + ); + }); + + test("routes run_id to DELETE /v2/entities/run/:name/", async () => { + const { client, axiosDeleteMock } = createClientWithMockedAxios(); + await client.deleteUsers({ run_id: "run_1" }); + + expect(axiosDeleteMock).toHaveBeenCalledWith( + "/v2/entities/run/run_1/", + expect.any(Object), + ); + }); + + test("returns 'Entity deleted successfully.' for single entity", async () => { + const { client } = createClientWithMockedAxios(); + const result = await client.deleteUsers({ user_id: "u1" }); + expect(result.message).toBe("Entity deleted successfully."); + }); + + test("returns 'All users, agents, apps and runs deleted.' when no params given", async () => { + const extra = new Map(); + extra.set("/v1/entities/", { + status: 200, + body: createMockAllUsers([createMockUser({ name: "u1", type: "user" })]), + }); + setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + client.client.delete = jest + .fn() + .mockResolvedValue({ data: { message: "Deleted" } }); + + const result = await client.deleteUsers(); + expect(result.message).toBe("All users, agents, apps and runs deleted."); + }); + + test("throws when no entities exist to delete", async () => { + const extra = new Map(); + extra.set("/v1/entities/", { + status: 200, + body: createMockAllUsers([]), + }); + setupMockFetch(extra); + + const client = new MemoryClient({ + apiKey: TEST_API_KEY, + organizationId: TEST_ORG_ID, + projectId: TEST_PROJECT_ID, + }); + client.client.delete = jest.fn(); + + await expect(client.deleteUsers()).rejects.toThrow("No entities to delete"); + }); +}); + +// ─── deleteUser() (deprecated) ────────────────────────── + +describe("MemoryClient - deleteUser() (deprecated)", () => { + test("sends DELETE to /v1/entities/:type/:id/", async () => { + const extra = new Map(); + extra.set("/v1/entities/user/123/", { + status: 200, + body: { message: "Entity deleted successfully!" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.deleteUser({ + entity_id: 123 as never, + entity_type: "user", + }); + + expect( + findFetchCall(mock, "/v1/entities/user/123/", "DELETE"), + ).toBeDefined(); + }); + + test("defaults entity_type to 'user' when empty", async () => { + const extra = new Map(); + extra.set("/v1/entities/user/456/", { + status: 200, + body: { message: "Entity deleted successfully!" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.deleteUser({ entity_id: 456 as never, entity_type: "" }); + + expect( + findFetchCall(mock, "/v1/entities/user/456/", "DELETE"), + ).toBeDefined(); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.webhooks.test.ts b/mem0-ts/src/client/tests/memoryClient.webhooks.test.ts new file mode 100644 index 000000000..6b053b2c0 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.webhooks.test.ts @@ -0,0 +1,154 @@ +/** + * MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook. + * Tests verify request URL and HTTP method, not mock response values. + */ +import { MemoryClient } from "../mem0"; +import { WebhookEvent } from "../mem0.types"; +import { TEST_API_KEY, TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers"; +import { + setupMockFetch, + findFetchCall, + getFetchBody, + installConsoleSuppression, +} from "./setup"; + +installConsoleSuppression(); + +describe("MemoryClient - Webhooks", () => { + test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => { + const extra = new Map(); + 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, + }); + await client.getWebhooks(); + + const call = mock.mock.calls.find( + (c: [string, RequestInit]) => + c[0].includes("/api/v1/webhooks/projects/") && !c[1]?.method, + ); + expect(call).toBeDefined(); + }); + + test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => { + const extra = new Map(); + 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: "", + }); + + expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined(); + }); + + test("createWebhook includes webhook payload in body", async () => { + const extra = new Map(); + 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!); + expect(body.name).toBe("new-hook"); + expect(body.url).toBe("https://example.com"); + }); + + test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => { + const extra = new Map(); + 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, + }); + + expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined(); + }); + + test("updateWebhook includes updated fields in body", async () => { + const extra = new Map(); + 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!); + expect(body.name).toBe("updated-hook"); + expect(body.url).toBe("https://new-url.com"); + }); + + test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => { + const extra = new Map(); + extra.set("/api/v1/webhooks/wh_1/", { + status: 200, + body: { message: "Webhook deleted" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.deleteWebhook({ webhookId: "wh_1" }); + + expect( + findFetchCall(mock, "/api/v1/webhooks/wh_1/", "DELETE"), + ).toBeDefined(); + }); +}); diff --git a/mem0-ts/src/client/tests/setup.ts b/mem0-ts/src/client/tests/setup.ts new file mode 100644 index 000000000..3eecccdab --- /dev/null +++ b/mem0-ts/src/client/tests/setup.ts @@ -0,0 +1,83 @@ +/** + * Shared test setup for MemoryClient unit tests. + * Provides mock fetch wiring, console suppression, and utility finders. + */ +import { + createMockFetch, + createStandardMockResponses, + MOCK_PING_RESPONSE, +} from "./helpers"; + +// ─── Global fetch mock + telemetry suppression ─────────── + +const originalFetch = global.fetch; + +export function setupMockFetch( + extraResponses?: Map, +): jest.Mock { + const responses = createStandardMockResponses(); + if (extraResponses) { + for (const [key, value] of extraResponses) { + responses.set(key, value); + } + } + const mockFetch = createMockFetch(responses); + global.fetch = mockFetch; + return mockFetch; +} + +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; + +export function installConsoleSuppression(): void { + beforeAll(() => { + jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + const msg = String(args[0] ?? ""); + if ( + msg.includes("Telemetry") || + msg.includes("Failed to initialize") || + msg.includes("Failed to capture") + ) { + return; + } + originalConsoleError(...args); + }); + jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + const msg = String(args[0] ?? ""); + if (msg.includes("telemetry") || msg.includes("Telemetry")) { + return; + } + originalConsoleWarn(...args); + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); +} + +// ─── Helper: find specific fetch calls ─────────────────── + +export function findFetchCall( + mock: jest.Mock, + urlPattern: string, + method?: string, +): [string, RequestInit] | undefined { + return mock.mock.calls.find((call: [string, RequestInit]) => { + const urlMatch = call[0].includes(urlPattern); + if (!method) return urlMatch; + return urlMatch && call[1]?.method === method; + }); +} + +export function getFetchBody( + call: [string, RequestInit], +): Record { + return JSON.parse(call[1].body as string); +} + +export { MOCK_PING_RESPONSE }; diff --git a/mem0-ts/src/common/exceptions.test.ts b/mem0-ts/src/common/exceptions.test.ts new file mode 100644 index 000000000..a04599526 --- /dev/null +++ b/mem0-ts/src/common/exceptions.test.ts @@ -0,0 +1,246 @@ +import { + MemoryError, + AuthenticationError, + RateLimitError, + ValidationError, + MemoryNotFoundError, + NetworkError, + ConfigurationError, + MemoryQuotaExceededError, + createExceptionFromResponse, + HTTP_STATUS_TO_EXCEPTION, +} from "./exceptions"; + +describe("MemoryError", () => { + const error = new MemoryError("test error", "MEM_001", { + details: { operation: "add" }, + suggestion: "Try again", + debugInfo: { requestId: "req_123" }, + }); + + test("is an instance of Error", () => { + expect(error).toBeInstanceOf(Error); + }); + + test("has correct message", () => { + expect(error.message).toBe("test error"); + }); + + test("has correct errorCode", () => { + expect(error.errorCode).toBe("MEM_001"); + }); + + test("has correct details", () => { + expect(error.details).toEqual({ operation: "add" }); + }); + + test("has correct suggestion", () => { + expect(error.suggestion).toBe("Try again"); + }); + + test("has correct debugInfo", () => { + expect(error.debugInfo).toEqual({ requestId: "req_123" }); + }); + + test("defaults details to empty object", () => { + const err = new MemoryError("test error", "MEM_001"); + expect(err.details).toEqual({}); + }); + + test("defaults suggestion to undefined", () => { + const err = new MemoryError("test error", "MEM_001"); + expect(err.suggestion).toBeUndefined(); + }); + + test("defaults debugInfo to empty object", () => { + const err = new MemoryError("test error", "MEM_001"); + expect(err.debugInfo).toEqual({}); + }); + + test("is throwable and catchable", () => { + expect(() => { + throw new MemoryError("fail", "MEM_001"); + }).toThrow("fail"); + }); +}); + +describe("Exception subclasses", () => { + const subclasses = [ + { Class: AuthenticationError, name: "AuthenticationError" }, + { Class: RateLimitError, name: "RateLimitError" }, + { Class: ValidationError, name: "ValidationError" }, + { Class: MemoryNotFoundError, name: "MemoryNotFoundError" }, + { Class: NetworkError, name: "NetworkError" }, + { Class: ConfigurationError, name: "ConfigurationError" }, + { Class: MemoryQuotaExceededError, name: "MemoryQuotaExceededError" }, + ] as const; + + test.each(subclasses)("$name extends MemoryError", ({ Class }) => { + const error = new Class("test", "CODE_001"); + expect(error).toBeInstanceOf(MemoryError); + }); + + test.each(subclasses)("$name extends Error", ({ Class }) => { + const error = new Class("test", "CODE_001"); + expect(error).toBeInstanceOf(Error); + }); + + test.each(subclasses)("$name has correct name", ({ Class, name }) => { + const error = new Class("test", "CODE_001"); + expect(error.name).toBe(name); + }); + + test.each(subclasses)("$name supports instanceof checks", ({ Class }) => { + const error = new Class("test", "CODE_001"); + expect(error instanceof Class).toBe(true); + }); +}); + +describe("createExceptionFromResponse", () => { + test("maps 401 to AuthenticationError", () => { + const error = createExceptionFromResponse(401, "Unauthorized"); + expect(error).toBeInstanceOf(AuthenticationError); + }); + + test("maps 401 to errorCode HTTP_401", () => { + const error = createExceptionFromResponse(401, "Unauthorized"); + expect(error.errorCode).toBe("HTTP_401"); + }); + + test("maps 401 to authentication suggestion", () => { + const error = createExceptionFromResponse(401, "Unauthorized"); + expect(error.suggestion).toBe( + "Please check your API key and authentication credentials", + ); + }); + + test("maps 429 to RateLimitError", () => { + const error = createExceptionFromResponse(429, "Too many requests", { + debugInfo: { retryAfter: 60 }, + }); + expect(error).toBeInstanceOf(RateLimitError); + }); + + test("maps 429 passes debugInfo through", () => { + const error = createExceptionFromResponse(429, "Too many requests", { + debugInfo: { retryAfter: 60 }, + }); + expect(error.debugInfo).toEqual({ retryAfter: 60 }); + }); + + test("maps 404 to MemoryNotFoundError", () => { + const error = createExceptionFromResponse(404, "Not found"); + expect(error).toBeInstanceOf(MemoryNotFoundError); + }); + + test("maps 400 to ValidationError", () => { + const error = createExceptionFromResponse(400, "Bad request"); + expect(error).toBeInstanceOf(ValidationError); + }); + + test("maps 413 to MemoryQuotaExceededError", () => { + const error = createExceptionFromResponse(413, "Quota exceeded"); + expect(error).toBeInstanceOf(MemoryQuotaExceededError); + }); + + test.each([502, 503, 504])("maps %i to NetworkError", (code) => { + const error = createExceptionFromResponse(code, "Service unavailable"); + expect(error).toBeInstanceOf(NetworkError); + }); + + test("maps 500 to MemoryError", () => { + const error = createExceptionFromResponse(500, "Internal error"); + expect(error).toBeInstanceOf(MemoryError); + }); + + test("maps 500 to errorCode HTTP_500", () => { + const error = createExceptionFromResponse(500, "Internal error"); + expect(error.errorCode).toBe("HTTP_500"); + }); + + test("maps unknown status to MemoryError", () => { + const error = createExceptionFromResponse(418, "I am a teapot"); + expect(error).toBeInstanceOf(MemoryError); + }); + + test("maps unknown status to correct errorCode", () => { + const error = createExceptionFromResponse(418, "I am a teapot"); + expect(error.errorCode).toBe("HTTP_418"); + }); + + test("maps unknown status to retry suggestion", () => { + const error = createExceptionFromResponse(418, "I am a teapot"); + expect(error.suggestion).toBe("Please try again later"); + }); + + test("uses response text as message", () => { + const error = createExceptionFromResponse(400, "Invalid user_id format"); + expect(error.message).toBe("Invalid user_id format"); + }); + + test("falls back to generic message when response text is empty", () => { + const error = createExceptionFromResponse(500, ""); + expect(error.message).toBe("HTTP 500 error"); + }); + + test("passes details through", () => { + const error = createExceptionFromResponse(400, "Bad request", { + details: { field: "user_id", value: "" }, + }); + expect(error.details).toEqual({ field: "user_id", value: "" }); + }); +}); + +describe("HTTP_STATUS_TO_EXCEPTION", () => { + test("maps 400 to ValidationError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[400]).toBe(ValidationError); + }); + + test("maps 401 to AuthenticationError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[401]).toBe(AuthenticationError); + }); + + test("maps 403 to AuthenticationError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[403]).toBe(AuthenticationError); + }); + + test("maps 404 to MemoryNotFoundError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[404]).toBe(MemoryNotFoundError); + }); + + test("maps 408 to NetworkError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[408]).toBe(NetworkError); + }); + + test("maps 409 to ValidationError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[409]).toBe(ValidationError); + }); + + test("maps 413 to MemoryQuotaExceededError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[413]).toBe(MemoryQuotaExceededError); + }); + + test("maps 422 to ValidationError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[422]).toBe(ValidationError); + }); + + test("maps 429 to RateLimitError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[429]).toBe(RateLimitError); + }); + + test("maps 500 to MemoryError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[500]).toBe(MemoryError); + }); + + test("maps 502 to NetworkError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[502]).toBe(NetworkError); + }); + + test("maps 503 to NetworkError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[503]).toBe(NetworkError); + }); + + test("maps 504 to NetworkError", () => { + expect(HTTP_STATUS_TO_EXCEPTION[504]).toBe(NetworkError); + }); +}); diff --git a/mem0-ts/src/common/exceptions.ts b/mem0-ts/src/common/exceptions.ts new file mode 100644 index 000000000..02ef7d6d6 --- /dev/null +++ b/mem0-ts/src/common/exceptions.ts @@ -0,0 +1,205 @@ +/** + * Structured exception classes for mem0 TypeScript SDK. + * + * Provides specific, actionable exceptions with error codes, suggestions, + * and debug information. Maps HTTP status codes to appropriate exception types. + * + * @example + * ```typescript + * import { RateLimitError, MemoryNotFoundError } from 'mem0ai' + * + * try { + * await client.get(memoryId) + * } catch (e) { + * if (e instanceof MemoryNotFoundError) { + * console.log(e.suggestion) // "The requested resource was not found" + * } else if (e instanceof RateLimitError) { + * await sleep(e.debugInfo.retryAfter ?? 60) + * } + * } + * ``` + */ + +export interface MemoryErrorOptions { + details?: Record; + suggestion?: string; + debugInfo?: Record; +} + +/** + * Base exception for all memory-related errors. + * + * Every mem0 exception includes an error code for programmatic handling, + * optional details, a user-friendly suggestion, and debug information. + */ +export class MemoryError extends Error { + readonly errorCode: string; + readonly details: Record; + readonly suggestion?: string; + readonly debugInfo: Record; + + constructor( + message: string, + errorCode: string, + options: MemoryErrorOptions = {}, + ) { + super(message); + this.name = "MemoryError"; + this.errorCode = errorCode; + this.details = options.details ?? {}; + this.suggestion = options.suggestion; + this.debugInfo = options.debugInfo ?? {}; + + // Fix prototype chain for instanceof checks + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Raised when authentication fails (401, 403). */ +export class AuthenticationError extends MemoryError { + constructor( + message: string, + errorCode: string, + options?: MemoryErrorOptions, + ) { + super(message, errorCode, options); + this.name = "AuthenticationError"; + } +} + +/** Raised when rate limits are exceeded (429). */ +export class RateLimitError extends MemoryError { + constructor( + message: string, + errorCode: string, + options?: MemoryErrorOptions, + ) { + super(message, errorCode, options); + this.name = "RateLimitError"; + } +} + +/** Raised when input validation fails (400, 409, 422). */ +export class ValidationError extends MemoryError { + constructor( + message: string, + errorCode: string, + options?: MemoryErrorOptions, + ) { + super(message, errorCode, options); + this.name = "ValidationError"; + } +} + +/** Raised when a memory is not found (404). */ +export class MemoryNotFoundError extends MemoryError { + constructor( + message: string, + errorCode: string, + options?: MemoryErrorOptions, + ) { + super(message, errorCode, options); + this.name = "MemoryNotFoundError"; + } +} + +/** Raised when network connectivity issues occur (408, 502, 503, 504). */ +export class NetworkError extends MemoryError { + constructor( + message: string, + errorCode: string, + options?: MemoryErrorOptions, + ) { + super(message, errorCode, options); + this.name = "NetworkError"; + } +} + +/** Raised when client configuration is invalid. */ +export class ConfigurationError extends MemoryError { + constructor( + message: string, + errorCode: string, + options?: MemoryErrorOptions, + ) { + super(message, errorCode, options); + this.name = "ConfigurationError"; + } +} + +/** Raised when memory quota is exceeded (413). */ +export class MemoryQuotaExceededError extends MemoryError { + constructor( + message: string, + errorCode: string, + options?: MemoryErrorOptions, + ) { + super(message, errorCode, options); + this.name = "MemoryQuotaExceededError"; + } +} + +// ─── HTTP Status → Exception Mapping ───────────────────── + +type MemoryErrorConstructor = new ( + message: string, + errorCode: string, + options?: MemoryErrorOptions, +) => MemoryError; + +export const HTTP_STATUS_TO_EXCEPTION: Record = + { + 400: ValidationError, + 401: AuthenticationError, + 403: AuthenticationError, + 404: MemoryNotFoundError, + 408: NetworkError, + 409: ValidationError, + 413: MemoryQuotaExceededError, + 422: ValidationError, + 429: RateLimitError, + 500: MemoryError, + 502: NetworkError, + 503: NetworkError, + 504: NetworkError, + }; + +const HTTP_SUGGESTIONS: Record = { + 400: "Please check your request parameters and try again", + 401: "Please check your API key and authentication credentials", + 403: "You don't have permission to perform this operation", + 404: "The requested resource was not found", + 408: "Request timed out. Please try again", + 409: "Resource conflict. Please check your request", + 413: "Request too large. Please reduce the size of your request", + 422: "Invalid request data. Please check your input", + 429: "Rate limit exceeded. Please wait before making more requests", + 500: "Internal server error. Please try again later", + 502: "Service temporarily unavailable. Please try again later", + 503: "Service unavailable. Please try again later", + 504: "Gateway timeout. Please try again later", +}; + +/** + * Create an appropriate exception based on HTTP response status code. + * + * @param statusCode - HTTP status code from the response + * @param responseText - Response body text + * @param options - Additional error context (details, debugInfo) + * @returns An instance of the appropriate MemoryError subclass + */ +export function createExceptionFromResponse( + statusCode: number, + responseText: string, + options: Omit = {}, +): MemoryError { + const ExceptionClass = HTTP_STATUS_TO_EXCEPTION[statusCode] ?? MemoryError; + const errorCode = `HTTP_${statusCode}`; + const suggestion = HTTP_SUGGESTIONS[statusCode] ?? "Please try again later"; + + return new ExceptionClass( + responseText || `HTTP ${statusCode} error`, + errorCode, + { ...options, suggestion }, + ); +} diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index 5e2eb9c56..6a4a85376 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -23,11 +23,15 @@ export class ConfigManager { // Normalize snake_case keys from Python SDK / OpenClaw configs const baseURL = userConf?.baseURL ?? - (userConf as Record)?.lmstudio_base_url as string | undefined ?? + ((userConf as Record)?.lmstudio_base_url as + | string + | undefined) ?? userConf?.url; const embeddingDims = userConf?.embeddingDims ?? - (userConf as Record)?.embedding_dims as number | undefined; + ((userConf as Record)?.embedding_dims as + | number + | undefined); return { apiKey: @@ -103,7 +107,9 @@ export class ConfigManager { // Normalize snake_case keys from Python SDK / OpenClaw configs const llmBaseURL = userConf?.baseURL ?? - (userConf as Record)?.lmstudio_base_url as string | undefined ?? + ((userConf as Record)?.lmstudio_base_url as + | string + | undefined) ?? defaultConf.baseURL; return { diff --git a/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts b/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts index d5d537e49..2a9d29253 100644 --- a/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts +++ b/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts @@ -32,7 +32,7 @@ describe("backward compat: ConfigManager.mergeConfig", () => { expect(cfg.embedder.provider).toBe("openai"); expect(cfg.vectorStore.provider).toBe("memory"); expect(cfg.vectorStore.config.collectionName).toBe("memories"); - expect(cfg.vectorStore.config.dimension).toBe(1536); + expect(cfg.vectorStore.config.dimension).toBeUndefined(); expect(cfg.llm.provider).toBe("openai"); expect(cfg.historyStore).toBeDefined(); expect(cfg.historyStore!.provider).toBe("sqlite"); diff --git a/mem0-ts/src/oss/src/vector_stores/qdrant.ts b/mem0-ts/src/oss/src/vector_stores/qdrant.ts index d0ac42d64..699760465 100644 --- a/mem0-ts/src/oss/src/vector_stores/qdrant.ts +++ b/mem0-ts/src/oss/src/vector_stores/qdrant.ts @@ -282,7 +282,11 @@ export class Qdrant implements VectorStore { }, }); } catch (error: any) { - if (error?.status === 409 || error?.status === 401 || error?.status === 403) { + if ( + error?.status === 409 || + error?.status === 401 || + error?.status === 403 + ) { // Collection already exists — verify configuration for the main collection if (name === this.collectionName) { try { diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index f821e7de0..8444564db 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -190,7 +190,10 @@ describe("ConfigManager", () => { const cfg = ConfigManager.mergeConfig({ embedder: { provider: "lmstudio", - config: { model: "nomic-embed-text-v1.5", embedding_dims: 768 } as any, + config: { + model: "nomic-embed-text-v1.5", + embedding_dims: 768, + } as any, }, vectorStore: { provider: "memory", config: {} }, llm: baseLlm, @@ -313,7 +316,11 @@ describe("ConfigManager", () => { }, vectorStore: { provider: "qdrant", - config: { host: "192.168.200.12", port: 6333, checkCompatibility: false }, + config: { + host: "192.168.200.12", + port: 6333, + checkCompatibility: false, + }, }, llm: { provider: "lmstudio", @@ -326,7 +333,9 @@ describe("ConfigManager", () => { expect(cfg.embedder.provider).toBe("lmstudio"); expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1"); - expect(cfg.embedder.config.model).toBe("text-embedding-gte-qwen2-1.5b-instruct"); + expect(cfg.embedder.config.model).toBe( + "text-embedding-gte-qwen2-1.5b-instruct", + ); expect(cfg.embedder.config.embeddingDims).toBe(1536); expect(cfg.llm.provider).toBe("lmstudio"); @@ -409,12 +418,18 @@ describe("Memory – LM Studio end-to-end flow", () => { const mem = new MemoryClass({ embedder: { provider: "lmstudio", - config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }, + config: { + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + }, }, vectorStore: { provider: "memory", config: { collectionName: "test" } }, llm: { provider: "lmstudio", - config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }, + config: { + model: "meta-llama-3.1-70b", + baseURL: "http://localhost:1234/v1", + }, }, disableHistory: true, }); @@ -423,11 +438,17 @@ describe("Memory – LM Studio end-to-end flow", () => { expect(mockEmbedderFactory.create).toHaveBeenCalledWith( "lmstudio", - expect.objectContaining({ model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }), + expect.objectContaining({ + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + }), ); expect(mockLlmFactory.create).toHaveBeenCalledWith( "lmstudio", - expect.objectContaining({ model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }), + expect.objectContaining({ + model: "meta-llama-3.1-70b", + baseURL: "http://localhost:1234/v1", + }), ); }); @@ -435,10 +456,16 @@ describe("Memory – LM Studio end-to-end flow", () => { const mem = new MemoryClass({ embedder: { provider: "lmstudio", - config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }, + config: { + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + }, }, vectorStore: { provider: "qdrant", config: { collectionName: "test" } }, - llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } }, + llm: { + provider: "lmstudio", + config: { baseURL: "http://localhost:1234/v1" }, + }, disableHistory: true, }); @@ -462,7 +489,10 @@ describe("Memory – LM Studio end-to-end flow", () => { vectorStore: { provider: "memory", config: { collectionName: "test" } }, llm: { provider: "lmstudio", - config: { model: "openai/gpt-oss-20b", lmstudio_base_url: "http://192.168.200.83:1234/v1" } as any, + config: { + model: "openai/gpt-oss-20b", + lmstudio_base_url: "http://192.168.200.83:1234/v1", + } as any, }, disableHistory: true, }); @@ -471,32 +501,57 @@ describe("Memory – LM Studio end-to-end flow", () => { expect(mockEmbedderFactory.create).toHaveBeenCalledWith( "lmstudio", - expect.objectContaining({ model: "text-embedding-gte-qwen2-1.5b-instruct", baseURL: "http://192.168.200.83:1234/v1" }), + expect.objectContaining({ + model: "text-embedding-gte-qwen2-1.5b-instruct", + baseURL: "http://192.168.200.83:1234/v1", + }), ); expect(mockLlmFactory.create).toHaveBeenCalledWith( "lmstudio", - expect.objectContaining({ model: "openai/gpt-oss-20b", baseURL: "http://192.168.200.83:1234/v1" }), + expect.objectContaining({ + model: "openai/gpt-oss-20b", + baseURL: "http://192.168.200.83:1234/v1", + }), ); }); it("search flow works with lmstudio embedder", async () => { - mockVStore.search.mockResolvedValueOnce([{ - id: "mem-1", - payload: { data: "User likes hiking", user_id: "u1", hash: "abc123", created_at: "2026-01-01" }, - score: 0.95, - }]); + mockVStore.search.mockResolvedValueOnce([ + { + id: "mem-1", + payload: { + data: "User likes hiking", + user_id: "u1", + hash: "abc123", + created_at: "2026-01-01", + }, + score: 0.95, + }, + ]); const mem = new MemoryClass({ embedder: { provider: "lmstudio", - config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 }, + config: { + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + embeddingDims: 768, + }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test", dimension: 768 }, + }, + llm: { + provider: "lmstudio", + config: { baseURL: "http://localhost:1234/v1" }, }, - vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } }, - llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } }, disableHistory: true, }); - const result = await mem.search("What does the user like?", { userId: "u1" }); + const result = await mem.search("What does the user like?", { + userId: "u1", + }); expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?"); expect(mockVStore.search).toHaveBeenCalled(); @@ -505,19 +560,31 @@ describe("Memory – LM Studio end-to-end flow", () => { }); it("add flow works with lmstudio LLM for fact extraction", async () => { - mockLlm.generateResponse.mockResolvedValueOnce('{"facts":["User loves sushi"]}'); + mockLlm.generateResponse.mockResolvedValueOnce( + '{"facts":["User loves sushi"]}', + ); mockVStore.search.mockResolvedValue([]); mockVStore.list.mockResolvedValue([[], 0]); const mem = new MemoryClass({ embedder: { provider: "lmstudio", - config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 }, + config: { + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + embeddingDims: 768, + }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test", dimension: 768 }, }, - vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } }, llm: { provider: "lmstudio", - config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }, + config: { + model: "meta-llama-3.1-70b", + baseURL: "http://localhost:1234/v1", + }, }, disableHistory: true, }); diff --git a/mem0-ts/src/oss/tests/factory.test.ts b/mem0-ts/src/oss/tests/factory.test.ts deleted file mode 100644 index 9f513e000..000000000 --- a/mem0-ts/src/oss/tests/factory.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/// -import { - EmbedderFactory, - VectorStoreFactory, -} from "../src/utils/factory"; -import { LMStudioEmbedder } from "../src/embeddings/lmstudio"; -import { AzureAISearch } from "../src/vector_stores/azure_ai_search"; - -describe("EmbedderFactory", () => { - describe("create", () => { - it("should create LM Studio embedder with baseURL", () => { - const embedder = EmbedderFactory.create("lmstudio", { - model: "text-embedding-gte-qwen2-1.5b-instruct", - baseURL: "http://localhost:1234/v1", - }); - - expect(embedder).toBeInstanceOf(LMStudioEmbedder); - }); - - it("should throw error for unsupported embedder provider", () => { - expect(() => { - EmbedderFactory.create("unsupported-embedder", {}); - }).toThrow("Unsupported embedder provider: unsupported-embedder"); - }); - }); -}); - -describe("VectorStoreFactory", () => { - describe("create", () => { - it("should create Azure AI Search vector store", () => { - const config = { - collectionName: "test-memories", - serviceName: "test-service", - apiKey: "test-api-key", - embeddingModelDims: 1536, - compressionType: "none" as const, - useFloat16: false, - hybridSearch: false, - vectorFilterMode: "preFilter" as const, - }; - - const vectorStore = VectorStoreFactory.create("azure-ai-search", config); - - expect(vectorStore).toBeInstanceOf(AzureAISearch); - }); - - it("should create memory vector store", () => { - const config = { - collectionName: "test-memories", - dimension: 1536, - }; - - const vectorStore = VectorStoreFactory.create("memory", config); - - expect(vectorStore).toBeDefined(); - expect(vectorStore.constructor.name).toBe("MemoryVectorStore"); - }); - - it("should throw error for unsupported provider", () => { - const config = {}; - - expect(() => { - VectorStoreFactory.create("unsupported-provider", config); - }).toThrow("Unsupported vector store provider: unsupported-provider"); - }); - }); -}); diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts new file mode 100644 index 000000000..efac4e497 --- /dev/null +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -0,0 +1,287 @@ +/** + * Factory unit tests — EmbedderFactory, LLMFactory, VectorStoreFactory, HistoryManagerFactory. + * Mocks all provider modules to avoid external dependency crashes. + */ +/// + +// Mock all provider modules before importing factory +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "openai-embedder", config })), +})); +jest.mock("../src/embeddings/ollama", () => ({ + OllamaEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "ollama-embedder", config })), +})); +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "google-embedder", config })), +})); +jest.mock("../src/embeddings/azure", () => ({ + AzureOpenAIEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "azure-embedder", config })), +})); +jest.mock("../src/embeddings/langchain", () => ({ + LangchainEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "langchain-embedder", config })), +})); +jest.mock("../src/embeddings/lmstudio", () => ({ + LMStudioEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "lmstudio-embedder", config })), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest + .fn() + .mockImplementation((config) => ({ type: "openai-llm", config })), +})); +jest.mock("../src/llms/openai_structured", () => ({ + OpenAIStructuredLLM: jest.fn().mockImplementation((config) => ({ + type: "openai-structured-llm", + config, + })), +})); +jest.mock("../src/llms/anthropic", () => ({ + AnthropicLLM: jest + .fn() + .mockImplementation((config) => ({ type: "anthropic-llm", config })), +})); +jest.mock("../src/llms/groq", () => ({ + GroqLLM: jest + .fn() + .mockImplementation((config) => ({ type: "groq-llm", config })), +})); +jest.mock("../src/llms/ollama", () => ({ + OllamaLLM: jest + .fn() + .mockImplementation((config) => ({ type: "ollama-llm", config })), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest + .fn() + .mockImplementation((config) => ({ type: "google-llm", config })), +})); +jest.mock("../src/llms/azure", () => ({ + AzureOpenAILLM: jest + .fn() + .mockImplementation((config) => ({ type: "azure-llm", config })), +})); +jest.mock("../src/llms/mistral", () => ({ + MistralLLM: jest + .fn() + .mockImplementation((config) => ({ type: "mistral-llm", config })), +})); +jest.mock("../src/llms/langchain", () => ({ + LangchainLLM: jest + .fn() + .mockImplementation((config) => ({ type: "langchain-llm", config })), +})); +jest.mock("../src/llms/lmstudio", () => ({ + LMStudioLLM: jest + .fn() + .mockImplementation((config) => ({ type: "lmstudio-llm", config })), +})); + +jest.mock("../src/vector_stores/qdrant", () => ({ + Qdrant: jest + .fn() + .mockImplementation((config) => ({ type: "qdrant", config })), +})); +jest.mock("../src/vector_stores/redis", () => ({ + RedisDB: jest + .fn() + .mockImplementation((config) => ({ type: "redis", config })), +})); +jest.mock("../src/vector_stores/supabase", () => ({ + SupabaseDB: jest + .fn() + .mockImplementation((config) => ({ type: "supabase", config })), +})); +jest.mock("../src/vector_stores/langchain", () => ({ + LangchainVectorStore: jest + .fn() + .mockImplementation((config) => ({ type: "langchain-vs", config })), +})); +jest.mock("../src/vector_stores/vectorize", () => ({ + VectorizeDB: jest + .fn() + .mockImplementation((config) => ({ type: "vectorize", config })), +})); +jest.mock("../src/vector_stores/azure_ai_search", () => ({ + AzureAISearch: jest + .fn() + .mockImplementation((config) => ({ type: "azure-ai-search", config })), +})); +jest.mock("../src/storage/SupabaseHistoryManager", () => ({ + SupabaseHistoryManager: jest + .fn() + .mockImplementation((config) => ({ type: "supabase-history", config })), +})); + +import { + EmbedderFactory, + LLMFactory, + VectorStoreFactory, + HistoryManagerFactory, +} from "../src/utils/factory"; +import type { + EmbeddingConfig, + LLMConfig, + VectorStoreConfig, + HistoryStoreConfig, +} from "../src/types"; + +const dummyEmbedConfig: EmbeddingConfig = { apiKey: "test" }; +const dummyLLMConfig: LLMConfig = { apiKey: "test" }; +const dummyVSConfig: VectorStoreConfig = { + collectionName: "test", + dimension: 1536, +}; + +// ─── EmbedderFactory ──────────────────────────────────── + +describe("EmbedderFactory", () => { + test.each([ + ["openai"], + ["ollama"], + ["google"], + ["gemini"], + ["azure_openai"], + ["langchain"], + ["lmstudio"], + ])("creates embedder for provider '%s'", (provider) => { + expect(() => + EmbedderFactory.create(provider, dummyEmbedConfig), + ).not.toThrow(); + }); + + test("is case-insensitive", () => { + expect(() => + EmbedderFactory.create("OpenAI", dummyEmbedConfig), + ).not.toThrow(); + }); + + test("throws for unsupported provider", () => { + expect(() => + EmbedderFactory.create("nonexistent", dummyEmbedConfig), + ).toThrow("Unsupported embedder provider: nonexistent"); + }); + + test("passes config to created embedder", () => { + const config: EmbeddingConfig = { apiKey: "my-key", model: "my-model" }; + const result = EmbedderFactory.create("openai", config) as any; + expect(result.config).toBe(config); + }); +}); + +// ─── LLMFactory ───────────────────────────────────────── + +describe("LLMFactory", () => { + test.each([ + ["openai"], + ["openai_structured"], + ["anthropic"], + ["groq"], + ["ollama"], + ["google"], + ["gemini"], + ["azure_openai"], + ["mistral"], + ["langchain"], + ["lmstudio"], + ])("creates LLM for provider '%s'", (provider) => { + expect(() => LLMFactory.create(provider, dummyLLMConfig)).not.toThrow(); + }); + + test("is case-insensitive", () => { + expect(() => LLMFactory.create("Anthropic", dummyLLMConfig)).not.toThrow(); + }); + + test("throws for unsupported provider", () => { + expect(() => LLMFactory.create("nonexistent", dummyLLMConfig)).toThrow( + "Unsupported LLM provider: nonexistent", + ); + }); + + test("passes config to created LLM", () => { + const config: LLMConfig = { apiKey: "my-key", model: "gpt-4" }; + const result = LLMFactory.create("openai", config) as any; + expect(result.config).toBe(config); + }); +}); + +// ─── VectorStoreFactory ───────────────────────────────── + +describe("VectorStoreFactory", () => { + test("creates memory vector store", () => { + // MemoryVectorStore is real (not mocked) — needs valid config + expect(() => + VectorStoreFactory.create("memory", { + collectionName: "test", + dimension: 4, + }), + ).not.toThrow(); + }); + + test.each([ + ["qdrant"], + ["redis"], + ["supabase"], + ["langchain"], + ["vectorize"], + ["azure-ai-search"], + ])("creates vector store for provider '%s'", (provider) => { + expect(() => + VectorStoreFactory.create(provider, dummyVSConfig), + ).not.toThrow(); + }); + + test("throws for unsupported provider", () => { + expect(() => + VectorStoreFactory.create("nonexistent", dummyVSConfig), + ).toThrow("Unsupported vector store provider: nonexistent"); + }); +}); + +// ─── HistoryManagerFactory ────────────────────────────── + +describe("HistoryManagerFactory", () => { + test("creates SQLite history manager", () => { + const config: HistoryStoreConfig = { + provider: "sqlite", + config: { historyDbPath: ":memory:" }, + }; + expect(() => HistoryManagerFactory.create("sqlite", config)).not.toThrow(); + }); + + test("creates supabase history manager", () => { + const config: HistoryStoreConfig = { + provider: "supabase", + config: { supabaseUrl: "http://test", supabaseKey: "key" }, + }; + expect(() => + HistoryManagerFactory.create("supabase", config), + ).not.toThrow(); + }); + + test("creates memory history manager", () => { + const config: HistoryStoreConfig = { + provider: "memory", + config: {}, + }; + expect(() => HistoryManagerFactory.create("memory", config)).not.toThrow(); + }); + + test("throws for unsupported provider", () => { + const config: HistoryStoreConfig = { provider: "bad", config: {} }; + expect(() => HistoryManagerFactory.create("bad", config)).toThrow( + "Unsupported history store provider: bad", + ); + }); +}); diff --git a/mem0-ts/src/oss/tests/lmstudio-embedder.test.ts b/mem0-ts/src/oss/tests/lmstudio-embedder.test.ts index cb5d0442f..9c6b1b80c 100644 --- a/mem0-ts/src/oss/tests/lmstudio-embedder.test.ts +++ b/mem0-ts/src/oss/tests/lmstudio-embedder.test.ts @@ -61,12 +61,12 @@ describe("LMStudioEmbedder (unit)", () => { }); it("embedBatch() returns vectors for multiple inputs", async () => { - const mockBatch = [[0.1, 0.2], [0.3, 0.4]]; + const mockBatch = [ + [0.1, 0.2], + [0.3, 0.4], + ]; mockCreate.mockResolvedValueOnce({ - data: [ - { embedding: mockBatch[0] }, - { embedding: mockBatch[1] }, - ], + data: [{ embedding: mockBatch[0] }, { embedding: mockBatch[1] }], }); const embedder = new LMStudioEmbedder({ diff --git a/mem0-ts/src/oss/tests/lmstudio-integration.test.ts b/mem0-ts/src/oss/tests/lmstudio-integration.test.ts index 9a145ee98..839381434 100644 --- a/mem0-ts/src/oss/tests/lmstudio-integration.test.ts +++ b/mem0-ts/src/oss/tests/lmstudio-integration.test.ts @@ -20,17 +20,26 @@ const describeIf = RUN_INTEGRATION ? describe : describe.skip; jest.setTimeout(120_000); -async function listModels(): Promise<{ embedding: string | null; chat: string | null }> { +async function listModels(): Promise<{ + embedding: string | null; + chat: string | null; +}> { const res = await fetch(`${LMSTUDIO_BASE_URL}/models`); const body = await res.json(); const models: any[] = body.data || []; - const embedding = models.find((m) => m.id.includes("embed") || m.id.includes("nomic")); - const chat = models.find((m) => !m.id.includes("embed") && !m.id.includes("nomic")); + const embedding = models.find( + (m) => m.id.includes("embed") || m.id.includes("nomic"), + ); + const chat = models.find( + (m) => !m.id.includes("embed") && !m.id.includes("nomic"), + ); return { embedding: embedding?.id ?? null, chat: chat?.id ?? null }; } function cosineSim(a: number[], b: number[]): number { - let dot = 0, normA = 0, normB = 0; + let dot = 0, + normA = 0, + normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; @@ -46,7 +55,10 @@ describeIf("LM Studio Integration", () => { expect(res.ok).toBe(true); const body = await res.json(); expect(body.data).toBeDefined(); - console.log("Loaded models:", body.data.map((m: any) => m.id)); + console.log( + "Loaded models:", + body.data.map((m: any) => m.id), + ); }); // ─── Embedder ──────────────────────────────────────────────────────── @@ -58,7 +70,10 @@ describeIf("LM Studio Integration", () => { const models = await listModels(); if (!models.embedding) throw new Error("No embedding model loaded"); modelId = models.embedding; - embedder = new LMStudioEmbedder({ baseURL: LMSTUDIO_BASE_URL, model: modelId }); + embedder = new LMStudioEmbedder({ + baseURL: LMSTUDIO_BASE_URL, + model: modelId, + }); }); it("embed() returns a numeric vector", async () => { @@ -94,7 +109,9 @@ describeIf("LM Studio Integration", () => { ]); const simSimilar = cosineSim(v1, v2); const simDifferent = cosineSim(v1, v3); - console.log(` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`); + console.log( + ` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`, + ); expect(Number.isFinite(simSimilar)).toBe(true); expect(Number.isFinite(simDifferent)).toBe(true); expect(simSimilar).toBeGreaterThan(simDifferent); diff --git a/mem0-ts/src/oss/tests/lmstudio-llm.test.ts b/mem0-ts/src/oss/tests/lmstudio-llm.test.ts index e3df824d1..9edeff548 100644 --- a/mem0-ts/src/oss/tests/lmstudio-llm.test.ts +++ b/mem0-ts/src/oss/tests/lmstudio-llm.test.ts @@ -18,9 +18,15 @@ describe("LMStudioLLM (unit)", () => { it("generateResponse() returns a text response", async () => { mockCreate.mockResolvedValueOnce({ - choices: [{ - message: { content: "Hello, world!", role: "assistant", tool_calls: null }, - }], + choices: [ + { + message: { + content: "Hello, world!", + role: "assistant", + tool_calls: null, + }, + }, + ], }); const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); @@ -34,15 +40,22 @@ describe("LMStudioLLM (unit)", () => { it("generateResponse() handles tool calls", async () => { mockCreate.mockResolvedValueOnce({ - choices: [{ - message: { - content: "", - role: "assistant", - tool_calls: [{ - function: { name: "get_weather", arguments: '{"city": "London"}' }, - }], + choices: [ + { + message: { + content: "", + role: "assistant", + tool_calls: [ + { + function: { + name: "get_weather", + arguments: '{"city": "London"}', + }, + }, + ], + }, }, - }], + ], }); const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); @@ -71,9 +84,11 @@ describe("LMStudioLLM (unit)", () => { it("generateChat() returns LLMResponse shape", async () => { mockCreate.mockResolvedValueOnce({ - choices: [{ - message: { content: "I can help with that.", role: "assistant" }, - }], + choices: [ + { + message: { content: "I can help with that.", role: "assistant" }, + }, + ], }); const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); @@ -81,7 +96,10 @@ describe("LMStudioLLM (unit)", () => { { role: "user", content: "Help me" }, ]); - expect(result).toEqual({ content: "I can help with that.", role: "assistant" }); + expect(result).toEqual({ + content: "I can help with that.", + role: "assistant", + }); }); it("generateChat() wraps API errors with a clear message", async () => { diff --git a/mem0-ts/src/oss/tests/memory.add.test.ts b/mem0-ts/src/oss/tests/memory.add.test.ts new file mode 100644 index 000000000..9d2126ebd --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.add.test.ts @@ -0,0 +1,164 @@ +/** + * OSS Memory unit tests — add() with inference, without inference, filter validation, metadata. + * Content-based LLM mock: system-prompt calls → facts, user-only calls → memory actions. + */ +/// +import { Memory } from "../src/memory"; +import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types"; + +jest.setTimeout(15000); + +// Mock Google modules to prevent @google/genai crash in CI +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest + .fn() + .mockImplementation( + (messages: Array<{ role: string; content: string }>) => { + const hasSystemRole = messages.some((m) => m.role === "system"); + if (hasSystemRole) { + return JSON.stringify({ facts: ["extracted fact from input"] }); + } + return JSON.stringify({ + memory: [ + { + id: "new", + event: "ADD", + text: "extracted fact from input", + old_memory: "", + new_memory: "extracted fact from input", + }, + ], + }); + }, + ), + })), +})); + +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)), + embeddingDims: 1536, + })), +})); + +function createMemory(overrides: Partial = {}): Memory { + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: `test-add-${Date.now()}`, dimension: 1536 }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-4-turbo-preview" }, + }, + historyDbPath: ":memory:", + ...overrides, + }); +} + +describe("Memory - add()", () => { + let memory: Memory; + const userId = `add_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("returns SearchResult with results array for string input", async () => { + const result: SearchResult = await memory.add("I am a software engineer", { + userId, + }); + expect(Array.isArray(result.results)).toBe(true); + }); + + test("returns at least one result with an id", async () => { + const result: SearchResult = await memory.add("I am a software engineer", { + userId, + }); + expect(result.results.length).toBeGreaterThan(0); + expect(result.results[0].id).toBeDefined(); + }); + + test("result item has a memory string field", async () => { + const result: SearchResult = await memory.add("I am a software engineer", { + userId, + }); + expect(typeof result.results[0].memory).toBe("string"); + }); + + test("accepts Message[] input", async () => { + const messages = [ + { role: "user", content: "What is your favorite city?" }, + { role: "assistant", content: "I love Paris." }, + ]; + const result: SearchResult = await memory.add(messages, { userId }); + expect(result.results.length).toBeGreaterThan(0); + }); + + test("works with agentId filter instead of userId", async () => { + const result: SearchResult = await memory.add("test", { + agentId: "agent_1", + }); + expect(result.results.length).toBeGreaterThan(0); + }); + + test("works with runId filter instead of userId", async () => { + const result: SearchResult = await memory.add("test", { runId: "run_1" }); + expect(result.results.length).toBeGreaterThan(0); + }); + + test("throws when no userId/agentId/runId provided", async () => { + await expect(memory.add("test", {} as any)).rejects.toThrow( + "One of the filters: userId, agentId or runId is required!", + ); + }); + + test("passes metadata through to stored memory", async () => { + const result: SearchResult = await memory.add("I love TypeScript", { + userId, + metadata: { source: "chat", tag: "programming" }, + }); + const stored: MemoryItem | null = await memory.get(result.results[0].id); + expect(stored).not.toBeNull(); + expect(stored!.metadata).toEqual( + expect.objectContaining({ source: "chat", tag: "programming" }), + ); + }); + + test("with infer=false skips LLM and stores messages directly", async () => { + const result: SearchResult = await memory.add("Direct storage content", { + userId, + infer: false, + }); + expect(result.results.length).toBeGreaterThan(0); + // When infer=false, the literal message text is stored + expect(result.results[0].memory).toBe("Direct storage content"); + }); + + test("with infer=false marks event as ADD in metadata", async () => { + const result: SearchResult = await memory.add("Direct fact", { + userId, + infer: false, + }); + expect(result.results[0].metadata).toEqual( + expect.objectContaining({ event: "ADD" }), + ); + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts new file mode 100644 index 000000000..b594572b5 --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -0,0 +1,358 @@ +/** + * OSS Memory unit tests — get, update, delete, deleteAll, getAll, search, history. + * Content-based LLM mock. Tests verify real behavior, not mock echoes. + */ +/// +import { Memory } from "../src/memory"; +import type { MemoryItem, SearchResult } from "../src/types"; + +jest.setTimeout(30000); + +// Mock Google modules to prevent @google/genai crash in CI +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest + .fn() + .mockImplementation( + (messages: Array<{ role: string; content: string }>) => { + const hasSystemRole = messages.some((m) => m.role === "system"); + if (hasSystemRole) { + return JSON.stringify({ facts: ["stored fact"] }); + } + return JSON.stringify({ + memory: [ + { + id: "new", + event: "ADD", + text: "stored fact", + old_memory: "", + new_memory: "stored fact", + }, + ], + }); + }, + ), + })), +})); + +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)), + embeddingDims: 1536, + })), +})); + +function createMemory(): Memory { + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-crud-${Date.now()}-${Math.random()}`, + dimension: 1536, + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-4-turbo-preview" }, + }, + historyDbPath: ":memory:", + }); +} + +// ─── get() ─────────────────────────────────────────────── + +describe("Memory - get()", () => { + let memory: Memory; + const userId = `get_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("returns the memory matching the ID from add()", async () => { + const addResult: SearchResult = await memory.add("I love AI", { userId }); + const id = addResult.results[0].id; + const item: MemoryItem | null = await memory.get(id); + expect(item).not.toBeNull(); + expect(item!.id).toBe(id); + }); + + test("returns a string for the memory field", async () => { + const addResult: SearchResult = await memory.add("Testing get", { + userId, + }); + const item: MemoryItem | null = await memory.get(addResult.results[0].id); + expect(typeof item!.memory).toBe("string"); + }); + + test("returns null for non-existent ID", async () => { + const item = await memory.get("nonexistent-uuid-12345"); + expect(item).toBeNull(); + }); + + test("returns hash and createdAt on stored memory", async () => { + const addResult: SearchResult = await memory.add("Hash test", { userId }); + const item: MemoryItem | null = await memory.get(addResult.results[0].id); + expect(typeof item!.hash).toBe("string"); + expect(item!.createdAt).toBeDefined(); + expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date"); + }); +}); + +// ─── update() ──────────────────────────────────────────── + +describe("Memory - update()", () => { + let memory: Memory; + const userId = `update_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + // Use infer: false for update tests — bypasses LLM, gives us a stable ID + test("returns success message", async () => { + const addResult: SearchResult = await memory.add("Original", { + userId, + infer: false, + }); + const id = addResult.results[0].id; + const result = await memory.update(id, "Updated"); + expect(result.message).toBe("Memory updated successfully!"); + }); + + test("persists the updated text", async () => { + const addResult: SearchResult = await memory.add("Before update", { + userId, + infer: false, + }); + const id = addResult.results[0].id; + await memory.update(id, "After update"); + const item: MemoryItem | null = await memory.get(id); + expect(item!.memory).toBe("After update"); + }); + + test("preserves createdAt and sets updatedAt", async () => { + const addResult: SearchResult = await memory.add("Timestamp test", { + userId, + infer: false, + }); + const id = addResult.results[0].id; + const before: MemoryItem | null = await memory.get(id); + const originalCreatedAt = before!.createdAt; + + await memory.update(id, "New text"); + const after: MemoryItem | null = await memory.get(id); + expect(after!.createdAt).toBe(originalCreatedAt); + expect(after!.updatedAt).toBeDefined(); + }); + + test("updates the hash", async () => { + const addResult: SearchResult = await memory.add("Hash change", { + userId, + infer: false, + }); + const id = addResult.results[0].id; + const before: MemoryItem | null = await memory.get(id); + await memory.update(id, "Completely different text"); + const after: MemoryItem | null = await memory.get(id); + expect(after!.hash).not.toBe(before!.hash); + }); +}); + +// ─── delete() ──────────────────────────────────────────── + +describe("Memory - delete()", () => { + let memory: Memory; + const userId = `delete_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("returns success message", async () => { + const addResult: SearchResult = await memory.add("Delete me", { + userId, + infer: false, + }); + const result = await memory.delete(addResult.results[0].id); + expect(result.message).toBe("Memory deleted successfully!"); + }); + + test("get() returns null after deletion", async () => { + const addResult: SearchResult = await memory.add("Temporary", { + userId, + infer: false, + }); + const id = addResult.results[0].id; + await memory.delete(id); + expect(await memory.get(id)).toBeNull(); + }); +}); + +// ─── deleteAll() ───────────────────────────────────────── + +describe("Memory - deleteAll()", () => { + let memory: Memory; + const userId = `deleteall_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("removes all memories for the user and returns success", async () => { + await memory.add("Fact A", { userId }); + await memory.add("Fact B", { userId }); + const result = await memory.deleteAll({ userId }); + expect(result.message).toBe("Memories deleted successfully!"); + const remaining: SearchResult = await memory.getAll({ userId }); + expect(remaining.results).toHaveLength(0); + }); + + test("throws when no filter is provided", async () => { + await expect(memory.deleteAll({} as any)).rejects.toThrow( + "At least one filter is required", + ); + }); +}); + +// ─── getAll() ──────────────────────────────────────────── + +describe("Memory - getAll()", () => { + let memory: Memory; + const userId = `getall_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("returns all stored memories for the user", async () => { + await memory.add("First", { userId }); + await memory.add("Second", { userId }); + const result: SearchResult = await memory.getAll({ userId }); + expect(Array.isArray(result.results)).toBe(true); + expect(result.results.length).toBeGreaterThanOrEqual(2); + }); + + test("each result has id and memory fields", async () => { + const result: SearchResult = await memory.getAll({ userId }); + for (const item of result.results) { + expect(item.id).toBeDefined(); + expect(typeof item.memory).toBe("string"); + } + }); + + test("returns empty array when no memories exist", async () => { + const result: SearchResult = await memory.getAll({ + userId: "no_such_user", + }); + expect(result.results).toHaveLength(0); + }); +}); + +// ─── search() ──────────────────────────────────────────── + +describe("Memory - search()", () => { + let memory: Memory; + const userId = `search_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + await memory.add("I love TypeScript", { userId }); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("returns SearchResult with results array", async () => { + const result: SearchResult = await memory.search("TypeScript", { userId }); + expect(Array.isArray(result.results)).toBe(true); + }); + + test("returns results with score field", async () => { + const result: SearchResult = await memory.search("content", { userId }); + if (result.results.length > 0) { + expect(typeof result.results[0].score).toBe("number"); + } + }); + + test("throws when no userId/agentId/runId provided", async () => { + await expect(memory.search("query", {} as any)).rejects.toThrow( + "One of the filters: userId, agentId or runId is required!", + ); + }); + + test("returns empty results for user with no memories", async () => { + const result: SearchResult = await memory.search("query", { + userId: "empty_user", + }); + expect(result.results).toHaveLength(0); + }); +}); + +// ─── history() ─────────────────────────────────────────── + +describe("Memory - history()", () => { + let memory: Memory; + const userId = `history_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("records ADD event after add()", async () => { + const addResult: SearchResult = await memory.add("New fact", { userId }); + const history = await memory.history(addResult.results[0].id); + expect(Array.isArray(history)).toBe(true); + expect(history.length).toBeGreaterThan(0); + }); + + test("records additional entry after update()", async () => { + const addResult: SearchResult = await memory.add("Before", { userId }); + const id = addResult.results[0].id; + await memory.update(id, "After"); + const history = await memory.history(id); + expect(history.length).toBeGreaterThanOrEqual(2); + }); + + test("returns empty array for non-existent memory ID", async () => { + const history = await memory.history("nonexistent-id"); + expect(history).toHaveLength(0); + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.e2e.test.ts b/mem0-ts/src/oss/tests/memory.e2e.test.ts new file mode 100644 index 000000000..2f0f6cd2d --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.e2e.test.ts @@ -0,0 +1,292 @@ +/** + * OSS Memory E2E tests — exercises full add/get/search/update/delete flow with mocked LLM/embedder. + * Skipped by default. Run with: MEM0_RUN_E2E=1 npx jest memory.e2e.test.ts + */ +/// +import { Memory } from "../src/memory"; +import { MemoryItem, SearchResult } from "../src/types"; + +const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip; + +jest.setTimeout(30000); + +// Mock LLM and embedder so tests run without API keys. +// Content-based mock: system-prompt calls → facts, user-only calls → memory actions. +jest.mock("../src/embeddings/google", () => ({ GoogleEmbedder: jest.fn() })); +jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn() })); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest + .fn() + .mockImplementation( + (messages: Array<{ role: string; content: string }>) => { + const hasSystemRole = messages.some((m) => m.role === "system"); + if (hasSystemRole) { + return JSON.stringify({ + facts: ["John is a software engineer"], + }); + } + return JSON.stringify({ + memory: [ + { + id: "new", + event: "ADD", + text: "John is a software engineer", + old_memory: "", + new_memory: "John is a software engineer", + }, + ], + }); + }, + ), + })), +})); + +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)), + embeddingDims: 1536, + })), +})); + +describeOrSkip("Memory Class (E2E)", () => { + let memory: Memory; + const userId = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + beforeEach(async () => { + memory = new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test-memories", dimension: 1536 }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-4-turbo-preview" }, + }, + historyDbPath: ":memory:", + }); + await memory.reset(); + }); + + afterEach(async () => { + await memory.reset(); + }); + + describe("add() single memory", () => { + let result: SearchResult; + + beforeEach(async () => { + result = (await memory.add( + "Hi, my name is John and I am a software engineer.", + { userId }, + )) as SearchResult; + }); + + it("returns a defined result", () => { + expect(result).toBeDefined(); + }); + + it("returns results array", () => { + expect(Array.isArray(result.results)).toBe(true); + }); + + it("returns at least one result", () => { + expect(result.results.length).toBeGreaterThan(0); + }); + + it("returns result with an id", () => { + expect(result.results[0]?.id).toBeDefined(); + }); + }); + + describe("add() multiple messages", () => { + let result: SearchResult; + + beforeEach(async () => { + const messages = [ + { role: "user", content: "What is your favorite city?" }, + { role: "assistant", content: "I love Paris, it is my favorite city." }, + ]; + result = (await memory.add(messages, { userId })) as SearchResult; + }); + + it("returns results array", () => { + expect(Array.isArray(result.results)).toBe(true); + }); + + it("returns at least one result", () => { + expect(result.results.length).toBeGreaterThan(0); + }); + }); + + describe("get() single memory", () => { + let memoryItem: MemoryItem; + let memoryId: string; + + beforeEach(async () => { + const addResult = (await memory.add( + "I am a big advocate of using AI to make the world a better place", + { userId }, + )) as SearchResult; + memoryId = addResult.results[0].id; + memoryItem = (await memory.get(memoryId)) as MemoryItem; + }); + + it("returns the correct id", () => { + expect(memoryItem.id).toBe(memoryId); + }); + + it("returns a string memory", () => { + expect(typeof memoryItem.memory).toBe("string"); + }); + }); + + describe("update() memory", () => { + let memoryId: string; + + beforeEach(async () => { + const addResult = (await memory.add( + "I love speaking foreign languages especially Spanish", + { userId }, + )) as SearchResult; + memoryId = addResult.results[0].id; + }); + + it("returns success message", async () => { + const result = await memory.update(memoryId, "Updated content"); + expect(result.message).toBe("Memory updated successfully!"); + }); + + it("persists the updated content", async () => { + await memory.update(memoryId, "Updated content"); + const updated = (await memory.get(memoryId)) as MemoryItem; + expect(updated.memory).toBe("Updated content"); + }); + }); + + describe("getAll() memories for user", () => { + let result: SearchResult; + + beforeEach(async () => { + await memory.add("I love visiting new places in the winters", { userId }); + await memory.add("I like to rule the world", { userId }); + result = (await memory.getAll({ userId })) as SearchResult; + }); + + it("returns results array", () => { + expect(Array.isArray(result.results)).toBe(true); + }); + + it("returns at least two results", () => { + expect(result.results.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe("search() memories", () => { + let result: SearchResult; + + beforeEach(async () => { + await memory.add("I love programming in Python", { userId }); + await memory.add("JavaScript is my favorite language", { userId }); + result = (await memory.search("What programming languages do I know?", { + userId, + })) as SearchResult; + }); + + it("returns results array", () => { + expect(Array.isArray(result.results)).toBe(true); + }); + + it("returns at least one result", () => { + expect(result.results.length).toBeGreaterThan(0); + }); + }); + + describe("history() of a memory", () => { + let history: unknown[]; + + beforeEach(async () => { + const addResult = (await memory.add("I like swimming in warm water", { + userId, + })) as SearchResult; + const memoryId = addResult.results[0].id; + await memory.update(memoryId, "Updated content"); + history = await memory.history(memoryId); + }); + + it("returns an array", () => { + expect(Array.isArray(history)).toBe(true); + }); + + it("returns at least one entry", () => { + expect(history.length).toBeGreaterThan(0); + }); + }); + + describe("delete() a memory", () => { + it("returns null after deletion", async () => { + const addResult = (await memory.add("I love to drink vodka in summers", { + userId, + })) as SearchResult; + const memoryId = addResult.results[0].id; + await memory.delete(memoryId); + const result = await memory.get(memoryId); + expect(result).toBeNull(); + }); + }); + + describe("Memory with Custom Configuration", () => { + let customMemory: Memory; + + beforeEach(() => { + customMemory = new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test-memories", dimension: 1536 }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-4-turbo-preview" }, + }, + historyDbPath: ":memory:", + }); + }); + + afterEach(async () => { + await customMemory.reset(); + }); + + it("add() returns results with custom config", async () => { + const result = (await customMemory.add("I love programming in Python", { + userId, + })) as SearchResult; + expect(result.results.length).toBeGreaterThan(0); + }); + + it("search() returns results with custom config", async () => { + await customMemory.add("The weather in London is rainy today", { + userId, + }); + await customMemory.add("The temperature in Paris is 25 degrees", { + userId, + }); + const result = (await customMemory.search("What is the weather like?", { + userId, + })) as SearchResult; + expect(result.results.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.init.test.ts b/mem0-ts/src/oss/tests/memory.init.test.ts new file mode 100644 index 000000000..b8b228fd8 --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.init.test.ts @@ -0,0 +1,125 @@ +/** + * OSS Memory unit tests — constructor, initialization, config validation, reset. + * Mocks LLM/Embedder at module level. No API keys needed. + */ +/// +import { Memory } from "../src/memory"; +import type { MemoryConfig, SearchResult } from "../src/types"; + +jest.setTimeout(15000); + +// Mock Google modules to prevent @google/genai crash in CI +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +// ─── Content-based LLM mock (reviewer #9) ──────────────── +// Returns facts for system-prompt calls, memory actions for user-only calls. +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest + .fn() + .mockImplementation( + (messages: Array<{ role: string; content: string }>) => { + const hasSystemRole = messages.some((m) => m.role === "system"); + if (hasSystemRole) { + return JSON.stringify({ facts: ["test fact"] }); + } + return JSON.stringify({ + memory: [ + { + id: "new", + event: "ADD", + text: "test fact", + old_memory: "", + new_memory: "test fact", + }, + ], + }); + }, + ), + })), +})); + +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)), + embeddingDims: 1536, + })), +})); + +function createMemory(overrides: Partial = {}): Memory { + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test-init", dimension: 1536 }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-4-turbo-preview" }, + }, + historyDbPath: ":memory:", + ...overrides, + }); +} + +describe("Memory - Initialization", () => { + test("constructs without throwing with valid config", () => { + expect(() => createMemory()).not.toThrow(); + }); + + test("fromConfig creates instance from config dict", () => { + const config = { + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test", dimension: 1536 }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-4" }, + }, + }; + const mem = Memory.fromConfig(config); + expect(mem).toBeInstanceOf(Memory); + }); + + test("fromConfig throws on invalid config", () => { + expect(() => Memory.fromConfig({ invalid: true } as any)).toThrow(); + }); + + test("disableHistory=true uses DummyHistoryManager (no crash on history)", async () => { + const mem = createMemory({ disableHistory: true }); + // If DummyHistoryManager is used, history returns [] without error + const result = await mem.history("nonexistent-id"); + expect(Array.isArray(result)).toBe(true); + }); +}); + +describe("Memory - reset()", () => { + test("reset clears all stored memories", async () => { + const mem = createMemory(); + const userId = `reset_test_${Date.now()}`; + + await mem.add("Remember this fact", { userId }); + const before: SearchResult = await mem.getAll({ userId }); + expect(before.results.length).toBeGreaterThan(0); + + await mem.reset(); + + const after: SearchResult = await mem.getAll({ userId }); + expect(after.results).toHaveLength(0); + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.test.ts b/mem0-ts/src/oss/tests/memory.test.ts deleted file mode 100644 index e5374a996..000000000 --- a/mem0-ts/src/oss/tests/memory.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -/// -import { Memory } from "../src"; -import { MemoryItem, SearchResult } from "../src/types"; -import dotenv from "dotenv"; - -dotenv.config(); - -jest.setTimeout(30000); // Increase timeout to 30 seconds - -describe("Memory Class", () => { - let memory: Memory; - const userId = - Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); - - beforeEach(async () => { - // Initialize with default configuration - memory = new Memory({ - version: "v1.1", - embedder: { - provider: "openai", - config: { - apiKey: process.env.OPENAI_API_KEY || "", - model: "text-embedding-3-small", - }, - }, - vectorStore: { - provider: "memory", - config: { - collectionName: "test-memories", - dimension: 1536, - }, - }, - llm: { - provider: "openai", - config: { - apiKey: process.env.OPENAI_API_KEY || "", - model: "gpt-4-turbo-preview", - }, - }, - historyDbPath: ":memory:", // Use in-memory SQLite for tests - }); - // Reset all memories before each test - await memory.reset(); - }); - - afterEach(async () => { - // Clean up after each test - await memory.reset(); - }); - - describe("Basic Memory Operations", () => { - it("should add a single memory", async () => { - const result = (await memory.add( - "Hi, my name is John and I am a software engineer.", - userId, - )) as SearchResult; - - expect(result).toBeDefined(); - expect(result.results).toBeDefined(); - expect(Array.isArray(result.results)).toBe(true); - expect(result.results.length).toBeGreaterThan(0); - expect(result.results[0]?.id).toBeDefined(); - }); - - it("should add multiple messages", async () => { - const messages = [ - { role: "user", content: "What is your favorite city?" }, - { role: "assistant", content: "I love Paris, it is my favorite city." }, - ]; - - const result = (await memory.add(messages, userId)) as SearchResult; - - expect(result).toBeDefined(); - expect(result.results).toBeDefined(); - expect(Array.isArray(result.results)).toBe(true); - expect(result.results.length).toBeGreaterThan(0); - }); - - it("should get a single memory", async () => { - // First add a memory - const addResult = (await memory.add( - "I am a big advocate of using AI to make the world a better place", - userId, - )) as SearchResult; - - if (!addResult.results?.[0]?.id) { - throw new Error("Failed to create test memory"); - } - - const memoryId = addResult.results[0].id; - const result = (await memory.get(memoryId)) as MemoryItem; - - expect(result).toBeDefined(); - expect(result.id).toBe(memoryId); - expect(result.memory).toBeDefined(); - expect(typeof result.memory).toBe("string"); - }); - - it("should update a memory", async () => { - // First add a memory - const addResult = (await memory.add( - "I love speaking foreign languages especially Spanish", - userId, - )) as SearchResult; - - if (!addResult.results?.[0]?.id) { - throw new Error("Failed to create test memory"); - } - - const memoryId = addResult.results[0].id; - const updatedContent = "Updated content"; - const result = await memory.update(memoryId, updatedContent); - - expect(result).toBeDefined(); - expect(result.message).toBe("Memory updated successfully!"); - - // Verify the update by getting the memory - const updatedMemory = (await memory.get(memoryId)) as MemoryItem; - expect(updatedMemory.memory).toBe(updatedContent); - }); - - it("should get all memories for a user", async () => { - // Add a few memories - await memory.add("I love visiting new places in the winters", userId); - await memory.add("I like to rule the world", userId); - - const result = (await memory.getAll(userId)) as SearchResult; - - expect(result).toBeDefined(); - expect(Array.isArray(result.results)).toBe(true); - expect(result.results.length).toBeGreaterThanOrEqual(2); - }); - - it("should search memories", async () => { - // Add some test memories - await memory.add("I love programming in Python", userId); - await memory.add("JavaScript is my favorite language", userId); - - const result = (await memory.search( - "What programming languages do I know?", - userId, - )) as SearchResult; - - expect(result).toBeDefined(); - expect(Array.isArray(result.results)).toBe(true); - expect(result.results.length).toBeGreaterThan(0); - }); - - it("should get memory history", async () => { - // Add and update a memory to create history - const addResult = (await memory.add( - "I like swimming in warm water", - userId, - )) as SearchResult; - - if (!addResult.results?.[0]?.id) { - throw new Error("Failed to create test memory"); - } - - const memoryId = addResult.results[0].id; - await memory.update(memoryId, "Updated content"); - - const history = await memory.history(memoryId); - - expect(history).toBeDefined(); - expect(Array.isArray(history)).toBe(true); - expect(history.length).toBeGreaterThan(0); - }); - - it("should delete a memory", async () => { - // First add a memory - const addResult = (await memory.add( - "I love to drink vodka in summers", - userId, - )) as SearchResult; - - if (!addResult.results?.[0]?.id) { - throw new Error("Failed to create test memory"); - } - - const memoryId = addResult.results[0].id; - - // Delete the memory - await memory.delete(memoryId); - - // Try to get the deleted memory - should throw or return null - const result = await memory.get(memoryId); - expect(result).toBeNull(); - }); - }); - - describe("Memory with Custom Configuration", () => { - let customMemory: Memory; - - beforeEach(() => { - customMemory = new Memory({ - version: "v1.1", - embedder: { - provider: "openai", - config: { - apiKey: process.env.OPENAI_API_KEY || "", - model: "text-embedding-3-small", - }, - }, - vectorStore: { - provider: "memory", - config: { - collectionName: "test-memories", - dimension: 1536, - }, - }, - llm: { - provider: "openai", - config: { - apiKey: process.env.OPENAI_API_KEY || "", - model: "gpt-4-turbo-preview", - }, - }, - historyDbPath: ":memory:", // Use in-memory SQLite for tests - }); - }); - - afterEach(async () => { - await customMemory.reset(); - }); - - it("should work with custom configuration", async () => { - const result = (await customMemory.add( - "I love programming in Python", - userId, - )) as SearchResult; - - expect(result).toBeDefined(); - expect(result.results).toBeDefined(); - expect(Array.isArray(result.results)).toBe(true); - expect(result.results.length).toBeGreaterThan(0); - }); - - it("should perform semantic search with custom embeddings", async () => { - // Add test memories - await customMemory.add("The weather in London is rainy today", userId); - await customMemory.add("The temperature in Paris is 25 degrees", userId); - - const result = (await customMemory.search( - "What is the weather like?", - userId, - )) as SearchResult; - - expect(result).toBeDefined(); - expect(Array.isArray(result.results)).toBe(true); - // Results should be ordered by relevance - expect(result.results.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/mem0-ts/src/oss/tests/storage.unit.test.ts b/mem0-ts/src/oss/tests/storage.unit.test.ts new file mode 100644 index 000000000..98437974e --- /dev/null +++ b/mem0-ts/src/oss/tests/storage.unit.test.ts @@ -0,0 +1,228 @@ +/** + * Storage manager unit tests — SQLiteManager, DummyHistoryManager. + * Uses real in-memory SQLite, no external dependencies. + */ +/// +import { SQLiteManager } from "../src/storage/SQLiteManager"; +import { DummyHistoryManager } from "../src/storage/DummyHistoryManager"; +import { MemoryHistoryManager } from "../src/storage/MemoryHistoryManager"; + +// ─── SQLiteManager ────────────────────────────────────── + +describe("SQLiteManager", () => { + let db: SQLiteManager; + + beforeEach(() => { + db = new SQLiteManager(":memory:"); + }); + + afterEach(() => { + db.close(); + }); + + test("constructs without throwing", () => { + expect(db).toBeDefined(); + }); + + test("addHistory inserts a record retrievable by getHistory", async () => { + await db.addHistory( + "mem1", + null, + "new value", + "ADD", + "2026-01-01T00:00:00Z", + ); + const history = await db.getHistory("mem1"); + expect(history).toHaveLength(1); + expect(history[0].memory_id).toBe("mem1"); + expect(history[0].new_value).toBe("new value"); + expect(history[0].action).toBe("ADD"); + }); + + test("getHistory returns records in reverse chronological order", async () => { + await db.addHistory("mem1", null, "first", "ADD", "2026-01-01"); + await db.addHistory("mem1", "first", "second", "UPDATE", "2026-01-02"); + await db.addHistory("mem1", "second", "third", "UPDATE", "2026-01-03"); + const history = await db.getHistory("mem1"); + expect(history).toHaveLength(3); + // DESC order by id: most recent first + expect(history[0].new_value).toBe("third"); + expect(history[2].new_value).toBe("first"); + }); + + test("getHistory returns empty array for non-existent memory", async () => { + const history = await db.getHistory("nonexistent"); + expect(history).toHaveLength(0); + }); + + test("addHistory stores previous_value for UPDATE", async () => { + await db.addHistory("mem1", "old text", "new text", "UPDATE"); + const history = await db.getHistory("mem1"); + expect(history[0].previous_value).toBe("old text"); + expect(history[0].new_value).toBe("new text"); + }); + + test("addHistory stores null new_value for DELETE", async () => { + await db.addHistory( + "mem1", + "deleted text", + null, + "DELETE", + undefined, + undefined, + 1, + ); + const history = await db.getHistory("mem1"); + expect(history[0].action).toBe("DELETE"); + expect(history[0].new_value).toBeNull(); + expect(history[0].is_deleted).toBe(1); + }); + + test("reset clears all history and recreates table", async () => { + await db.addHistory("mem1", null, "data", "ADD"); + await db.addHistory("mem2", null, "data", "ADD"); + await db.reset(); + expect(await db.getHistory("mem1")).toHaveLength(0); + expect(await db.getHistory("mem2")).toHaveLength(0); + // Table still works after reset + await db.addHistory("mem3", null, "after reset", "ADD"); + expect(await db.getHistory("mem3")).toHaveLength(1); + }); + + test("stores createdAt and updatedAt timestamps", async () => { + const created = "2026-03-17T10:00:00Z"; + const updated = "2026-03-17T11:00:00Z"; + await db.addHistory("mem1", null, "data", "ADD", created, updated); + const history = await db.getHistory("mem1"); + expect(history[0].created_at).toBe(created); + expect(history[0].updated_at).toBe(updated); + }); + + test("handles multiple memories independently", async () => { + await db.addHistory("mem1", null, "data1", "ADD"); + await db.addHistory("mem2", null, "data2", "ADD"); + expect(await db.getHistory("mem1")).toHaveLength(1); + expect(await db.getHistory("mem2")).toHaveLength(1); + }); +}); + +// ─── DummyHistoryManager ──────────────────────────────── + +describe("DummyHistoryManager", () => { + let dummy: DummyHistoryManager; + + beforeEach(() => { + dummy = new DummyHistoryManager(); + }); + + test("constructs without throwing", () => { + expect(dummy).toBeDefined(); + }); + + test("addHistory is a no-op that resolves", async () => { + await expect( + dummy.addHistory("id", null, "val", "ADD"), + ).resolves.toBeUndefined(); + }); + + test("getHistory returns empty array", async () => { + const result = await dummy.getHistory("any-id"); + expect(result).toEqual([]); + }); + + test("reset resolves without throwing", async () => { + await expect(dummy.reset()).resolves.toBeUndefined(); + }); + + test("close does not throw", () => { + expect(() => dummy.close()).not.toThrow(); + }); +}); + +// ─── MemoryHistoryManager ─────────────────────────────── + +describe("MemoryHistoryManager", () => { + let mgr: MemoryHistoryManager; + + beforeEach(() => { + mgr = new MemoryHistoryManager(); + }); + + test("constructs without throwing", () => { + expect(mgr).toBeDefined(); + }); + + test("addHistory + getHistory round-trips correctly", async () => { + await mgr.addHistory( + "mem1", + null, + "new value", + "ADD", + "2026-01-01T00:00:00Z", + ); + const history = await mgr.getHistory("mem1"); + expect(history).toHaveLength(1); + expect(history[0].memory_id).toBe("mem1"); + expect(history[0].new_value).toBe("new value"); + expect(history[0].action).toBe("ADD"); + }); + + test("getHistory returns entries sorted by date descending", async () => { + await mgr.addHistory("mem1", null, "first", "ADD", "2026-01-01T00:00:00Z"); + await mgr.addHistory( + "mem1", + "first", + "second", + "UPDATE", + "2026-01-02T00:00:00Z", + ); + await mgr.addHistory( + "mem1", + "second", + "third", + "UPDATE", + "2026-01-03T00:00:00Z", + ); + const history = await mgr.getHistory("mem1"); + expect(history).toHaveLength(3); + expect(history[0].new_value).toBe("third"); + expect(history[2].new_value).toBe("first"); + }); + + test("getHistory returns empty array for non-existent memory", async () => { + expect(await mgr.getHistory("nonexistent")).toHaveLength(0); + }); + + test("getHistory caps at 100 entries", async () => { + for (let i = 0; i < 110; i++) { + await mgr.addHistory( + "mem1", + null, + `entry-${i}`, + "ADD", + `2026-01-01T00:${String(i).padStart(2, "0")}:00Z`, + ); + } + const history = await mgr.getHistory("mem1"); + expect(history).toHaveLength(100); + }); + + test("reset clears all entries", async () => { + await mgr.addHistory("mem1", null, "data", "ADD"); + await mgr.addHistory("mem2", null, "data", "ADD"); + await mgr.reset(); + expect(await mgr.getHistory("mem1")).toHaveLength(0); + expect(await mgr.getHistory("mem2")).toHaveLength(0); + }); + + test("close does not throw", () => { + expect(() => mgr.close()).not.toThrow(); + }); + + test("isolates history by memory_id", async () => { + await mgr.addHistory("mem1", null, "d1", "ADD"); + await mgr.addHistory("mem2", null, "d2", "ADD"); + expect(await mgr.getHistory("mem1")).toHaveLength(1); + expect(await mgr.getHistory("mem2")).toHaveLength(1); + }); +}); diff --git a/mem0-ts/src/oss/tests/vector-store.unit.test.ts b/mem0-ts/src/oss/tests/vector-store.unit.test.ts new file mode 100644 index 000000000..413c59cc7 --- /dev/null +++ b/mem0-ts/src/oss/tests/vector-store.unit.test.ts @@ -0,0 +1,198 @@ +/** + * MemoryVectorStore unit tests — insert, search, get, update, delete, list, cosine similarity. + * Uses real SQLite in-memory DB, no external dependencies. + */ +/// +import { MemoryVectorStore } from "../src/vector_stores/memory"; +import type { VectorStoreResult } from "../src/types"; + +const DIM = 4; // Small dimension for fast tests + +function createStore(): MemoryVectorStore { + return new MemoryVectorStore({ + collectionName: "test", + dimension: DIM, + dbPath: ":memory:", + }); +} + +function vec(values: number[]): number[] { + return values; +} + +describe("MemoryVectorStore - insert + get", () => { + let store: MemoryVectorStore; + + beforeAll(() => { + store = createStore(); + }); + + test("inserts and retrieves a vector by ID", async () => { + await store.insert( + [vec([1, 0, 0, 0])], + ["id1"], + [{ data: "hello", userId: "u1" }], + ); + const result: VectorStoreResult | null = await store.get("id1"); + expect(result).not.toBeNull(); + expect(result!.id).toBe("id1"); + expect(result!.payload.data).toBe("hello"); + }); + + test("returns null for non-existent ID", async () => { + const result = await store.get("nonexistent"); + expect(result).toBeNull(); + }); + + test("throws on dimension mismatch during insert", async () => { + await expect( + store.insert([vec([1, 0, 0])], ["bad"], [{ data: "x" }]), + ).rejects.toThrow("Vector dimension mismatch"); + }); +}); + +describe("MemoryVectorStore - search", () => { + let store: MemoryVectorStore; + + beforeAll(async () => { + store = createStore(); + await store.insert( + [vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0.9, 0.1, 0, 0])], + ["a", "b", "c"], + [ + { data: "north", userId: "u1" }, + { data: "east", userId: "u1" }, + { data: "north-ish", userId: "u2" }, + ], + ); + }); + + test("returns results sorted by cosine similarity descending", async () => { + const results: VectorStoreResult[] = await store.search( + vec([1, 0, 0, 0]), + 10, + ); + expect(results.length).toBeGreaterThan(0); + expect(results[0].id).toBe("a"); // exact match + // scores should be descending + for (let i = 1; i < results.length; i++) { + expect(results[i - 1].score!).toBeGreaterThanOrEqual(results[i].score!); + } + }); + + test("respects limit parameter", async () => { + const results = await store.search(vec([1, 0, 0, 0]), 1); + expect(results).toHaveLength(1); + }); + + test("filters by userId", async () => { + const results = await store.search(vec([1, 0, 0, 0]), 10, { userId: "u2" }); + expect(results.every((r) => r.payload.userId === "u2")).toBe(true); + }); + + test("returns empty when filter matches nothing", async () => { + const results = await store.search(vec([1, 0, 0, 0]), 10, { + userId: "nobody", + }); + expect(results).toHaveLength(0); + }); + + test("throws on query dimension mismatch", async () => { + await expect(store.search(vec([1, 0]), 10)).rejects.toThrow( + "Query dimension mismatch", + ); + }); +}); + +describe("MemoryVectorStore - update", () => { + let store: MemoryVectorStore; + + beforeAll(async () => { + store = createStore(); + await store.insert([vec([1, 0, 0, 0])], ["upd1"], [{ data: "original" }]); + }); + + test("updates payload and vector", async () => { + await store.update("upd1", vec([0, 1, 0, 0]), { data: "updated" }); + const result = await store.get("upd1"); + expect(result!.payload.data).toBe("updated"); + }); + + test("throws on dimension mismatch during update", async () => { + await expect( + store.update("upd1", vec([1, 0]), { data: "bad" }), + ).rejects.toThrow("Vector dimension mismatch"); + }); +}); + +describe("MemoryVectorStore - delete + deleteCol", () => { + test("delete removes a vector", async () => { + const store = createStore(); + await store.insert([vec([1, 0, 0, 0])], ["del1"], [{ data: "bye" }]); + await store.delete("del1"); + expect(await store.get("del1")).toBeNull(); + }); + + test("deleteCol clears all vectors", async () => { + const store = createStore(); + await store.insert( + [vec([1, 0, 0, 0]), vec([0, 1, 0, 0])], + ["x", "y"], + [{ data: "a" }, { data: "b" }], + ); + await store.deleteCol(); + const [results] = await store.list(); + expect(results).toHaveLength(0); + }); +}); + +describe("MemoryVectorStore - list", () => { + let store: MemoryVectorStore; + + beforeAll(async () => { + store = createStore(); + await store.insert( + [vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0, 0, 1, 0])], + ["l1", "l2", "l3"], + [ + { data: "a", userId: "u1" }, + { data: "b", userId: "u1" }, + { data: "c", userId: "u2" }, + ], + ); + }); + + test("returns all vectors without filter", async () => { + const [results, count] = await store.list(); + expect(count).toBe(3); + expect(results).toHaveLength(3); + }); + + test("filters by userId", async () => { + const [results, count] = await store.list({ userId: "u1" }); + expect(count).toBe(2); + expect(results.every((r) => r.payload.userId === "u1")).toBe(true); + }); + + test("respects limit", async () => { + const [results] = await store.list(undefined, 1); + expect(results).toHaveLength(1); + }); +}); + +describe("MemoryVectorStore - userId tracking", () => { + test("getUserId generates and persists a random ID", async () => { + const store = createStore(); + const id = await store.getUserId(); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + // Calling again returns same ID + expect(await store.getUserId()).toBe(id); + }); + + test("setUserId overrides the stored ID", async () => { + const store = createStore(); + await store.setUserId("custom-id"); + expect(await store.getUserId()).toBe("custom-id"); + }); +});