Files
mem0/integrations/openclaw/tests/config-file.test.ts
Saket Aryan 8fabc87f6e fix(openclaw): make keyFingerprint actually persist, and serialize flushes
Three findings from @kartik-mem0 on this PR. The first two mean the fingerprint
gate this PR added never worked, and the first would have broken the plugin.

keyFingerprint was not in ALLOWED_KEYS, and assertAllowedKeys throws on an
unknown key. So the first successful lookup wrote a config that every later load
of the plugin rejected outright. Added there and to the manifest schema, with a
round-trip test that parses a config carrying the field.

readPluginAuth builds its result field by field and did not include
keyFingerprint, so the comparison always ran against undefined, the resolved
email was never used again, and telemetry fell back to the API key hash for every
event. That is worse than the defect this PR set out to fix, which at least used
the email. Returned now, with a test against the real reader.

Both were invisible to the telemetry tests because those mock the config module,
and the mock returned a field the real reader drops. That is the actual lesson
here, so the new tests live in config-file.test.ts and config.test.ts against the
real implementations. Confirmed both fail without the fixes.

flush() also had no in-flight guard. Two overlapping flushes each detach the
queue and each prepend their own batch back on failure, so the later batch landed
in front of the earlier one and the truncation then dropped the OLDER events
first, inverting the priority the failure path exists to establish. Serialized
with a guard; a second caller returns and the queue waits for the next flush.
Regression test asserts one delivery in flight and the backlog order preserved.

core 37, openclaw 435, opencode 35, deepseek 47. Wire e2e 9 of 9, exit cost
unchanged at 6s failing and 12ms healthy.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 21:59:03 +05:30

280 lines
8.2 KiB
TypeScript

/**
* Tests for cli/config-file.ts — file-based config helpers.
*
* All filesystem operations are mocked via fs-safe.ts so tests never
* touch the real disk.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../fs-safe.ts", () => ({
readText: vi.fn(),
exists: vi.fn(),
writeText: vi.fn(),
mkdirp: vi.fn(),
unlink: vi.fn(),
}));
import { readText, exists, writeText, mkdirp } from "../fs-safe.ts";
import {
readPluginAuth,
writePluginAuth,
getBaseUrl,
DEFAULT_BASE_URL,
} from "../cli/config-file.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockExists = exists as ReturnType<typeof vi.fn>;
const mockReadText = readText as ReturnType<typeof vi.fn>;
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
function setConfigFile(obj: Record<string, unknown>): void {
mockExists.mockReturnValue(true);
mockReadText.mockReturnValue(JSON.stringify(obj));
}
function setNoFile(): void {
mockExists.mockReturnValue(false);
}
beforeEach(() => {
vi.resetAllMocks();
});
// ---------------------------------------------------------------------------
// readPluginAuth
// ---------------------------------------------------------------------------
describe("readPluginAuth", () => {
it("returns empty object when config file does not exist", () => {
setNoFile();
expect(readPluginAuth()).toEqual({});
});
it("returns empty object when config has no plugins section", () => {
setConfigFile({ someOtherKey: true });
expect(readPluginAuth()).toEqual({});
});
it("reads all fields correctly from nested config", () => {
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: {
apiKey: "sk-test-123",
baseUrl: "https://custom.api.com",
userId: "user-1",
mode: "platform",
autoRecall: true,
autoCapture: false,
topK: 10,
},
},
},
},
});
const auth = readPluginAuth();
expect(auth).toEqual({
apiKey: "sk-test-123",
baseUrl: "https://custom.api.com",
userId: "user-1",
mode: "platform",
autoRecall: true,
autoCapture: false,
topK: 10,
});
});
it("handles snake_case aliases (api_key, base_url, user_id)", () => {
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: {
api_key: "sk-snake",
base_url: "https://snake.api.com",
user_id: "user-snake",
},
},
},
},
});
const auth = readPluginAuth();
expect(auth.apiKey).toBe("sk-snake");
expect(auth.baseUrl).toBe("https://snake.api.com");
expect(auth.userId).toBe("user-snake");
});
it("throws error when JSON is invalid (prevents config destruction)", () => {
mockExists.mockReturnValue(true);
mockReadText.mockReturnValue("not valid json {{{");
expect(() => readPluginAuth()).toThrow(/Failed to parse[\s\S]*Fix the JSON syntax error/);
});
it("returns empty object when config file is empty", () => {
mockExists.mockReturnValue(true);
mockReadText.mockReturnValue(" ");
expect(readPluginAuth()).toEqual({});
});
});
// ---------------------------------------------------------------------------
// writePluginAuth
// ---------------------------------------------------------------------------
describe("writePluginAuth", () => {
it("creates nested structure from scratch when no config exists", () => {
setNoFile();
// exists returns false for both the file (readFullConfig) and the dir (writeFullConfig)
mockExists.mockReturnValue(false);
writePluginAuth({ apiKey: "sk-new", userId: "u1" });
expect(mockMkdirp).toHaveBeenCalled();
expect(mockWriteText).toHaveBeenCalledOnce();
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
expect(written.plugins.entries["openclaw-mem0"].enabled).toBe(true);
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
"sk-new",
);
expect(written.plugins.entries["openclaw-mem0"].config.userId).toBe("u1");
});
it("merges into existing config preserving other data", () => {
setConfigFile({
otherSetting: "keep-me",
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: {
apiKey: "sk-old",
mode: "platform",
},
},
},
},
});
writePluginAuth({ baseUrl: "https://new.api.com" });
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
// Existing fields preserved
expect(written.otherSetting).toBe("keep-me");
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
"sk-old",
);
expect(written.plugins.entries["openclaw-mem0"].config.mode).toBe(
"platform",
);
// New field added
expect(written.plugins.entries["openclaw-mem0"].config.baseUrl).toBe(
"https://new.api.com",
);
});
it("creates directory if missing", () => {
// File doesn't exist (readFullConfig returns {}), dir doesn't exist
mockExists.mockReturnValue(false);
writePluginAuth({ apiKey: "sk-test" });
expect(mockMkdirp).toHaveBeenCalledWith(
expect.stringContaining(".openclaw"),
0o700,
);
});
it("skips undefined values", () => {
setNoFile();
mockExists.mockReturnValue(false);
writePluginAuth({
apiKey: "sk-set",
baseUrl: undefined,
userId: undefined,
});
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
const cfg = written.plugins.entries["openclaw-mem0"].config;
expect(cfg.apiKey).toBe("sk-set");
expect(cfg).not.toHaveProperty("baseUrl");
expect(cfg).not.toHaveProperty("userId");
});
});
// ---------------------------------------------------------------------------
// getBaseUrl
// ---------------------------------------------------------------------------
describe("getBaseUrl", () => {
it("returns configured URL when baseUrl is set", () => {
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: { baseUrl: "https://custom.example.com" },
},
},
},
});
expect(getBaseUrl()).toBe("https://custom.example.com");
});
it("returns default URL when baseUrl is not configured", () => {
setNoFile();
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
});
});
// ---------------------------------------------------------------------------
// keyFingerprint round trip
// ---------------------------------------------------------------------------
describe("keyFingerprint survives a write and read", () => {
it("readPluginAuth returns a persisted keyFingerprint", () => {
// It did not. readPluginAuth builds its result field by field, and this one
// was missing, so every fingerprint comparison ran against undefined, the
// resolved email was never used again, and telemetry silently fell back to
// the API key hash. The telemetry tests could not see it because they mock
// this module and their mock returned the field the real reader dropped.
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
config: {
apiKey: "m0-test",
userEmail: "person@example.com",
keyFingerprint: "0123456789abcdef",
},
},
},
},
});
expect(readPluginAuth().keyFingerprint).toBe("0123456789abcdef");
});
it("writePluginAuth persists it where readPluginAuth looks", () => {
setConfigFile({ plugins: { entries: { "openclaw-mem0": { config: {} } } } });
writePluginAuth({ userEmail: "person@example.com", keyFingerprint: "abc123" });
const written = JSON.parse(mockWriteText.mock.calls.at(-1)![1] as string);
const cfg = written.plugins.entries["openclaw-mem0"].config;
expect(cfg.keyFingerprint).toBe("abc123");
expect(cfg.userEmail).toBe("person@example.com");
});
});