411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
/**
|
|
* Tests for PostHog identity stitching in the TS MemoryClient.
|
|
*
|
|
* Covers $identify firing, idempotency via pair markers, and the node/browser
|
|
* gate. Mocks fs and fetch; never touches the real ~/.mem0/config.json.
|
|
*/
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
import { MemoryClient } from "../mem0";
|
|
import { telemetry } from "../telemetry";
|
|
import {
|
|
getOrCreateMem0UserId,
|
|
isMem0Aliased,
|
|
markMem0Aliased,
|
|
readMem0AnonIds,
|
|
} from "../config";
|
|
import { TEST_API_KEY } from "./helpers";
|
|
import { setupMockFetch, installConsoleSuppression } from "./setup";
|
|
|
|
installConsoleSuppression();
|
|
|
|
function setupMockFetchWithPostHog(): jest.Mock {
|
|
return setupMockFetch(
|
|
new Map([["us.i.posthog.com", { status: 200, body: "ok" }]]),
|
|
);
|
|
}
|
|
|
|
// ─── config.ts (node-only fs read/write) ──────────────────────
|
|
|
|
describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
|
let tmpHome: string;
|
|
const originalMem0Dir = process.env.MEM0_DIR;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-test-"));
|
|
process.env.MEM0_DIR = tmpHome;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (fs.existsSync(tmpHome)) {
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
}
|
|
if (originalMem0Dir === undefined) {
|
|
delete process.env.MEM0_DIR;
|
|
} else {
|
|
process.env.MEM0_DIR = originalMem0Dir;
|
|
}
|
|
});
|
|
|
|
test("returns null when config file does not exist", async () => {
|
|
expect(await readMem0AnonIds()).toBeNull();
|
|
});
|
|
|
|
test("reads OSS user_id only", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({ user_id: "oss-uuid" }),
|
|
);
|
|
const ids = await readMem0AnonIds();
|
|
expect(ids).toEqual({
|
|
oss: "oss-uuid",
|
|
cli: undefined,
|
|
aliasedPairs: [],
|
|
});
|
|
});
|
|
|
|
test("reads CLI anonymous_id and aliased_pairs", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({
|
|
telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] },
|
|
}),
|
|
);
|
|
const ids = await readMem0AnonIds();
|
|
expect(ids).toEqual({
|
|
oss: undefined,
|
|
cli: "cli-anon",
|
|
aliasedPairs: ["pair-marker"],
|
|
});
|
|
});
|
|
|
|
test("getOrCreateMem0UserId creates and reuses shared SDK user_id", async () => {
|
|
const first = await getOrCreateMem0UserId();
|
|
const second = await getOrCreateMem0UserId();
|
|
expect(first).toBeTruthy();
|
|
expect(second).toBe(first);
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
);
|
|
expect(written.user_id).toBe(first);
|
|
});
|
|
|
|
test("returns null on malformed JSON", async () => {
|
|
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
|
expect(await readMem0AnonIds()).toBeNull();
|
|
});
|
|
|
|
test("markMem0Aliased preserves other fields", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({
|
|
user_id: "oss-uuid",
|
|
telemetry: { anonymous_id: "cli-anon" },
|
|
}),
|
|
);
|
|
await markMem0Aliased("oss-uuid", "user@example.com");
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
);
|
|
expect(written.user_id).toBe("oss-uuid");
|
|
expect(written.telemetry.anonymous_id).toBe("cli-anon");
|
|
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
|
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
|
|
});
|
|
|
|
test("markMem0Aliased creates telemetry section when missing", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({ user_id: "oss-uuid" }),
|
|
);
|
|
await markMem0Aliased("oss-uuid", "user@example.com");
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
);
|
|
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
|
});
|
|
|
|
test("markMem0Aliased tracks each pair independently", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({ user_id: "oss-uuid" }),
|
|
);
|
|
await markMem0Aliased("oss-uuid", "user@example.com");
|
|
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
|
|
expect(await isMem0Aliased("other-uuid", "user@example.com")).toBe(false);
|
|
expect(await isMem0Aliased("oss-uuid", "other@example.com")).toBe(false);
|
|
});
|
|
|
|
test("markMem0Aliased does not throw when target dir is unwritable", async () => {
|
|
// Point at a path that cannot be written to (a file-as-dir collision).
|
|
fs.writeFileSync(path.join(tmpHome, "blocker"), "x");
|
|
process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir
|
|
await expect(
|
|
markMem0Aliased("oss-uuid", "user@example.com"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── telemetry.captureIdentify ───────────────────────────────
|
|
|
|
describe("telemetry.captureIdentify", () => {
|
|
test("fires $identify with $anon_distinct_id", async () => {
|
|
const fetchMock = jest.fn(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => "ok",
|
|
})) as unknown as typeof fetch;
|
|
global.fetch = fetchMock as any;
|
|
|
|
await telemetry.captureIdentify("anon-uuid", "user@example.com");
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [, init] = (fetchMock as jest.Mock).mock.calls[0];
|
|
const payload = JSON.parse(init.body);
|
|
expect(payload.event).toBe("$identify");
|
|
expect(payload.distinct_id).toBe("user@example.com");
|
|
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
|
expect(payload.properties.$process_person_profile).toBeUndefined();
|
|
});
|
|
|
|
test("skips when anon equals email", async () => {
|
|
const fetchMock = jest.fn() as unknown as typeof fetch;
|
|
global.fetch = fetchMock as any;
|
|
await telemetry.captureIdentify("user@example.com", "user@example.com");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("skips when either input is empty", async () => {
|
|
const fetchMock = jest.fn() as unknown as typeof fetch;
|
|
global.fetch = fetchMock as any;
|
|
await telemetry.captureIdentify("", "user@example.com");
|
|
await telemetry.captureIdentify("anon", "");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── MemoryClient init aliasing ──────────────────────────────
|
|
|
|
describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|
let tmpHome: string;
|
|
const originalMem0Dir = process.env.MEM0_DIR;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-init-"));
|
|
process.env.MEM0_DIR = tmpHome;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (fs.existsSync(tmpHome)) {
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
}
|
|
if (originalMem0Dir === undefined) {
|
|
delete process.env.MEM0_DIR;
|
|
} else {
|
|
process.env.MEM0_DIR = originalMem0Dir;
|
|
}
|
|
});
|
|
|
|
// Construct a non-initialised client so we can call _maybeAliasAnonToEmail
|
|
// in isolation (the real constructor's _initializeClient also fires it).
|
|
function makeStubClient(telemetryId: string): MemoryClient {
|
|
const client = Object.create(MemoryClient.prototype) as MemoryClient;
|
|
(client as any).apiKey = TEST_API_KEY;
|
|
(client as any).host = "https://api.mem0.ai";
|
|
(client as any).telemetryId = telemetryId;
|
|
return client;
|
|
}
|
|
|
|
test("fires $identify on first init and persists pair marker", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({ user_id: "oss-uuid" }),
|
|
);
|
|
const fetchMock = setupMockFetchWithPostHog();
|
|
|
|
const client = makeStubClient("test@example.com");
|
|
await (client as any)._maybeAliasAnonToEmail();
|
|
|
|
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
|
([, init]: [string, RequestInit]) => {
|
|
if (!init?.body) return false;
|
|
return JSON.parse(init.body as string).event === "$identify";
|
|
},
|
|
);
|
|
expect(identifyCalls.length).toBe(1);
|
|
const body = JSON.parse(identifyCalls[0][1].body);
|
|
expect(body.distinct_id).toBe("test@example.com");
|
|
expect(body.properties.$anon_distinct_id).toBe("oss-uuid");
|
|
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
);
|
|
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
|
});
|
|
|
|
test("platform-first init creates shared anon ID and identifies it", async () => {
|
|
const fetchMock = setupMockFetchWithPostHog();
|
|
|
|
const client = makeStubClient("test@example.com");
|
|
await (client as any)._maybeAliasAnonToEmail();
|
|
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
);
|
|
expect(written.user_id).toBeTruthy();
|
|
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
|
|
|
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
|
([, init]: [string, RequestInit]) => {
|
|
if (!init?.body) return false;
|
|
return JSON.parse(init.body as string).event === "$identify";
|
|
},
|
|
);
|
|
expect(identifyCalls.length).toBe(1);
|
|
const body = JSON.parse(identifyCalls[0][1].body);
|
|
expect(body.distinct_id).toBe("test@example.com");
|
|
expect(body.properties.$anon_distinct_id).toBe(written.user_id);
|
|
});
|
|
|
|
test("second init does not refire $identify", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({
|
|
user_id: "oss-uuid",
|
|
telemetry: {},
|
|
}),
|
|
);
|
|
await markMem0Aliased("oss-uuid", "test@example.com");
|
|
const fetchMock = setupMockFetchWithPostHog();
|
|
|
|
const client = makeStubClient("test@example.com");
|
|
await (client as any)._maybeAliasAnonToEmail();
|
|
|
|
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
|
([, init]: [string, RequestInit]) => {
|
|
if (!init?.body) return false;
|
|
return JSON.parse(init.body as string).event === "$identify";
|
|
},
|
|
);
|
|
expect(identifyCalls.length).toBe(0);
|
|
});
|
|
|
|
test("fires $identify for both OSS and CLI anon ids", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({
|
|
user_id: "oss-uuid",
|
|
telemetry: { anonymous_id: "cli-anon" },
|
|
}),
|
|
);
|
|
const fetchMock = setupMockFetchWithPostHog();
|
|
|
|
const client = makeStubClient("test@example.com");
|
|
await (client as any)._maybeAliasAnonToEmail();
|
|
|
|
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
|
([, init]: [string, RequestInit]) => {
|
|
if (!init?.body) return false;
|
|
return JSON.parse(init.body as string).event === "$identify";
|
|
},
|
|
);
|
|
expect(identifyCalls.length).toBe(2);
|
|
const anonIds = identifyCalls.map(
|
|
(c: [string, RequestInit]) =>
|
|
JSON.parse(c[1].body as string).properties.$anon_distinct_id,
|
|
);
|
|
expect(anonIds).toContain("oss-uuid");
|
|
expect(anonIds).toContain("cli-anon");
|
|
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
);
|
|
expect(written.telemetry.aliased_pairs).toHaveLength(2);
|
|
});
|
|
|
|
test("noop when telemetryId is not an email", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({ user_id: "oss-uuid" }),
|
|
);
|
|
const fetchMock = setupMockFetch();
|
|
|
|
const client = makeStubClient("not-an-email");
|
|
await (client as any)._maybeAliasAnonToEmail();
|
|
|
|
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
|
([, init]: [string, RequestInit]) => {
|
|
if (!init?.body) return false;
|
|
return JSON.parse(init.body as string).event === "$identify";
|
|
},
|
|
);
|
|
expect(identifyCalls.length).toBe(0);
|
|
});
|
|
|
|
test("does not throw when config read fails", async () => {
|
|
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
|
setupMockFetch();
|
|
|
|
const client = makeStubClient("test@example.com");
|
|
await expect(
|
|
(client as any)._maybeAliasAnonToEmail(),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("noop when telemetry disabled — no fs read, no fs write, no events", async () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, "config.json"),
|
|
JSON.stringify({ user_id: "oss-uuid" }),
|
|
);
|
|
const fetchMock = setupMockFetch();
|
|
|
|
jest.resetModules();
|
|
const original = process.env.MEM0_TELEMETRY;
|
|
process.env.MEM0_TELEMETRY = "false";
|
|
try {
|
|
const { MemoryClient: ColdClient } = await import("../mem0");
|
|
const client = Object.create(ColdClient.prototype);
|
|
client.apiKey = TEST_API_KEY;
|
|
client.host = "https://api.mem0.ai";
|
|
client.telemetryId = "test@example.com";
|
|
await client._maybeAliasAnonToEmail();
|
|
} finally {
|
|
if (original === undefined) delete process.env.MEM0_TELEMETRY;
|
|
else process.env.MEM0_TELEMETRY = original;
|
|
jest.resetModules();
|
|
}
|
|
|
|
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
|
([, init]: [string, RequestInit]) => {
|
|
if (!init?.body) return false;
|
|
return JSON.parse(init.body as string).event === "$identify";
|
|
},
|
|
);
|
|
expect(identifyCalls.length).toBe(0);
|
|
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
);
|
|
expect(written.telemetry?.aliased_pairs).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── Browser env path (no process.versions.node) ─────────────
|
|
|
|
describe("config.ts in browser-like environment", () => {
|
|
test("readMem0AnonIds returns null when not Node", async () => {
|
|
const originalProcess = global.process;
|
|
// @ts-expect-error force-undefining global to simulate a browser
|
|
delete global.process;
|
|
try {
|
|
jest.resetModules();
|
|
const { readMem0AnonIds: browserRead } = await import("../config");
|
|
expect(await browserRead()).toBeNull();
|
|
} finally {
|
|
global.process = originalProcess;
|
|
jest.resetModules();
|
|
}
|
|
});
|
|
});
|