ac76eb7568
Both methods reject top-level entity params with "Use filters: { userId:
"..." } instead", and docs/open-source/node-quickstart.mdx teaches that same
shape. But search() and getAll() only read filters.user_id / .agent_id /
.run_id, so following either one throws a second, contradictory error:
"filters must contain at least one of: user_id, agent_id, run_id".
Normalize the three camelCase aliases to snake_case before validation in both
methods, so the shape the SDK tells callers to use actually works. An explicit
snake_case value wins if both are supplied, and the camelCase key is dropped
rather than passed through to the vector store as a stray metadata field.
432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
/**
|
|
* Unit tests for OSS SDK input validation.
|
|
*
|
|
* Validates fixes for:
|
|
* - Undefined/null message handling in add()
|
|
* - Threshold bounds validation (must be 0-1) in search()
|
|
* - TopK validation (must be non-negative) in search() and getAll()
|
|
* - Whitespace-only entity ID rejection in add(), search(), getAll()
|
|
*/
|
|
/// <reference types="jest" />
|
|
import { Memory } from "../src/memory";
|
|
|
|
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().mockResolvedValue(
|
|
JSON.stringify({
|
|
memory: [{ id: "0", text: "test memory", attributed_to: "user" }],
|
|
}),
|
|
),
|
|
})),
|
|
}));
|
|
|
|
const mockEmbedding = new Array(1536).fill(0.1);
|
|
jest.mock("../src/embeddings/openai", () => ({
|
|
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
|
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
|
embedBatch: jest.fn().mockResolvedValue([mockEmbedding]),
|
|
})),
|
|
}));
|
|
|
|
describe("Memory Input Validation", () => {
|
|
let memory: Memory;
|
|
const testUserId = "test-user-validation";
|
|
|
|
beforeAll(async () => {
|
|
memory = new Memory({
|
|
version: "v1.1",
|
|
embedder: {
|
|
provider: "openai",
|
|
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
|
},
|
|
llm: {
|
|
provider: "openai",
|
|
config: { apiKey: "test-key", model: "gpt-5-mini" },
|
|
},
|
|
vectorStore: {
|
|
provider: "memory",
|
|
config: { collectionName: "validation-test" },
|
|
},
|
|
});
|
|
// Wait for initialization
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await memory.reset();
|
|
} catch (e) {
|
|
// ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe("add() message validation", () => {
|
|
it("should throw error when messages is undefined", async () => {
|
|
await expect(
|
|
// @ts-ignore - intentionally passing undefined
|
|
memory.add(undefined, { userId: testUserId }),
|
|
).rejects.toThrow("messages is required");
|
|
});
|
|
|
|
it("should throw error when messages is null", async () => {
|
|
await expect(
|
|
// @ts-ignore - intentionally passing null
|
|
memory.add(null, { userId: testUserId }),
|
|
).rejects.toThrow("messages is required");
|
|
});
|
|
|
|
it("should throw error when messages is an empty array", async () => {
|
|
await expect(memory.add([], { userId: testUserId })).rejects.toThrow(
|
|
"messages array cannot be empty",
|
|
);
|
|
});
|
|
|
|
it("should throw error when messages array contains only blank content", async () => {
|
|
await expect(
|
|
memory.add([{ role: "user", content: " " }], { userId: testUserId }),
|
|
).rejects.toThrow("messages array cannot contain only blank content");
|
|
});
|
|
|
|
it("should throw error when messages is an empty string", async () => {
|
|
await expect(memory.add(" ", { userId: testUserId })).rejects.toThrow(
|
|
"messages string cannot be empty",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("search() threshold validation", () => {
|
|
it("should throw error when threshold > 1.0", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
threshold: 1.5,
|
|
}),
|
|
).rejects.toThrow("Invalid threshold");
|
|
});
|
|
|
|
it("should throw error when threshold = 1.1", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
threshold: 1.1,
|
|
}),
|
|
).rejects.toThrow("Invalid threshold");
|
|
});
|
|
|
|
it("should throw error when threshold is negative", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
threshold: -0.5,
|
|
}),
|
|
).rejects.toThrow("Invalid threshold");
|
|
});
|
|
|
|
it("should throw error when threshold = -0.1", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
threshold: -0.1,
|
|
}),
|
|
).rejects.toThrow("Invalid threshold");
|
|
});
|
|
|
|
it("should accept threshold = 0 (edge case)", async () => {
|
|
const result = await memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
threshold: 0,
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result.results).toBeDefined();
|
|
});
|
|
|
|
it("should accept threshold = 1.0 (edge case)", async () => {
|
|
const result = await memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
threshold: 1.0,
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result.results).toBeDefined();
|
|
});
|
|
|
|
it("should accept threshold = 0.5 (normal valid value)", async () => {
|
|
const result = await memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
threshold: 0.5,
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result.results).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("search() topK validation", () => {
|
|
it("should throw error when topK is negative", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
topK: -5,
|
|
}),
|
|
).rejects.toThrow("Invalid topK");
|
|
});
|
|
|
|
it("should throw error when topK = -1", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
topK: -1,
|
|
}),
|
|
).rejects.toThrow("Invalid topK");
|
|
});
|
|
|
|
it("should accept topK = 0 (returns empty)", async () => {
|
|
const result = await memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
topK: 0,
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result.results).toBeDefined();
|
|
});
|
|
|
|
it("should accept topK = 20 (normal value)", async () => {
|
|
const result = await memory.search("test query", {
|
|
filters: { user_id: testUserId },
|
|
topK: 20,
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result.results).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("add() entity ID validation", () => {
|
|
it("should throw error when userId is whitespace-only", async () => {
|
|
await expect(
|
|
memory.add("test message", { userId: " " }),
|
|
).rejects.toThrow("Invalid userId");
|
|
});
|
|
|
|
it("should throw error when userId is tabs and newlines", async () => {
|
|
await expect(
|
|
memory.add("test message", { userId: "\t\n\t" }),
|
|
).rejects.toThrow("Invalid userId");
|
|
});
|
|
|
|
it("should throw error when agentId is whitespace-only", async () => {
|
|
await expect(
|
|
memory.add("test message", { agentId: " " }),
|
|
).rejects.toThrow("Invalid agentId");
|
|
});
|
|
|
|
it("should throw error when runId is whitespace-only", async () => {
|
|
await expect(
|
|
memory.add("test message", { runId: " " }),
|
|
).rejects.toThrow("Invalid runId");
|
|
});
|
|
|
|
it("should throw error when userId contains internal whitespace", async () => {
|
|
await expect(
|
|
memory.add("test message", { userId: "user 123" }),
|
|
).rejects.toThrow("Invalid userId: cannot contain whitespace");
|
|
});
|
|
|
|
it("should throw error when userId contains tab character", async () => {
|
|
await expect(
|
|
memory.add("test message", { userId: "user\t123" }),
|
|
).rejects.toThrow("Invalid userId: cannot contain whitespace");
|
|
});
|
|
|
|
it("should accept userId with leading/trailing whitespace (trimmed)", async () => {
|
|
// Should not throw - leading/trailing whitespace is trimmed
|
|
const result = await memory.add("test message", {
|
|
userId: " valid-user ",
|
|
});
|
|
expect(result).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("non-string entity ID coercion", () => {
|
|
it("coerces an integer user_id in getAll filters to its string form", async () => {
|
|
const listSpy = jest
|
|
.spyOn((memory as any).vectorStore, "list")
|
|
.mockResolvedValue([[], 0]);
|
|
|
|
await memory.getAll({ filters: { user_id: 42 as any } });
|
|
|
|
const passedFilters = listSpy.mock.calls[0][0] as Record<string, any>;
|
|
expect(passedFilters.user_id).toBe("42");
|
|
|
|
listSpy.mockRestore();
|
|
});
|
|
|
|
it("coerces an integer user_id in search filters to its string form", async () => {
|
|
const searchSpy = jest
|
|
.spyOn((memory as any).vectorStore, "search")
|
|
.mockResolvedValue([]);
|
|
|
|
await memory.search("q", { filters: { user_id: 42 as any } });
|
|
|
|
const passedFilters = searchSpy.mock.calls[0][2] as Record<string, any>;
|
|
expect(passedFilters.user_id).toBe("42");
|
|
|
|
searchSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("camelCase entity IDs in filters", () => {
|
|
it("maps camelCase filter keys to snake_case in search", async () => {
|
|
const searchSpy = jest
|
|
.spyOn((memory as any).vectorStore, "search")
|
|
.mockResolvedValue([]);
|
|
|
|
await memory.search("q", {
|
|
filters: { userId: "alice", agentId: "bot", runId: "run-1" },
|
|
});
|
|
|
|
const passedFilters = searchSpy.mock.calls[0][2] as Record<string, any>;
|
|
expect(passedFilters).toMatchObject({
|
|
user_id: "alice",
|
|
agent_id: "bot",
|
|
run_id: "run-1",
|
|
});
|
|
expect(passedFilters.userId).toBeUndefined();
|
|
expect(passedFilters.agentId).toBeUndefined();
|
|
expect(passedFilters.runId).toBeUndefined();
|
|
|
|
searchSpy.mockRestore();
|
|
});
|
|
|
|
it("maps camelCase filter keys to snake_case in getAll", async () => {
|
|
const listSpy = jest
|
|
.spyOn((memory as any).vectorStore, "list")
|
|
.mockResolvedValue([[], 0]);
|
|
|
|
await memory.getAll({ filters: { userId: "alice" } });
|
|
|
|
const passedFilters = listSpy.mock.calls[0][0] as Record<string, any>;
|
|
expect(passedFilters.user_id).toBe("alice");
|
|
expect(passedFilters.userId).toBeUndefined();
|
|
|
|
listSpy.mockRestore();
|
|
});
|
|
|
|
it("prefers an explicit snake_case value over its camelCase alias", async () => {
|
|
const listSpy = jest
|
|
.spyOn((memory as any).vectorStore, "list")
|
|
.mockResolvedValue([[], 0]);
|
|
|
|
await memory.getAll({
|
|
filters: { user_id: "snake", userId: "camel" },
|
|
});
|
|
|
|
const passedFilters = listSpy.mock.calls[0][0] as Record<string, any>;
|
|
expect(passedFilters.user_id).toBe("snake");
|
|
expect(passedFilters.userId).toBeUndefined();
|
|
|
|
listSpy.mockRestore();
|
|
});
|
|
|
|
it("validates camelCase filter values like their snake_case counterparts", async () => {
|
|
await expect(
|
|
memory.search("q", { filters: { userId: "user 123" } }),
|
|
).rejects.toThrow("Invalid user_id: cannot contain whitespace");
|
|
});
|
|
});
|
|
|
|
describe("search() filter entity ID validation", () => {
|
|
it("should throw error when user_id in filters is whitespace-only", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: " " },
|
|
}),
|
|
).rejects.toThrow("Invalid user_id");
|
|
});
|
|
|
|
it("should throw error when agent_id in filters is whitespace-only", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { agent_id: " " },
|
|
}),
|
|
).rejects.toThrow("Invalid agent_id");
|
|
});
|
|
|
|
it("should throw error when user_id contains internal whitespace", async () => {
|
|
await expect(
|
|
memory.search("test query", {
|
|
filters: { user_id: "user 123" },
|
|
}),
|
|
).rejects.toThrow("Invalid user_id: cannot contain whitespace");
|
|
});
|
|
|
|
it("should accept user_id with leading/trailing whitespace (trimmed)", async () => {
|
|
const result = await memory.search("test query", {
|
|
filters: { user_id: " valid-user " },
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result.results).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("getAll() validation", () => {
|
|
it("should throw error when user_id is whitespace-only", async () => {
|
|
await expect(
|
|
memory.getAll({ filters: { user_id: " " } }),
|
|
).rejects.toThrow("Invalid user_id");
|
|
});
|
|
|
|
it("should throw error when topK is negative", async () => {
|
|
await expect(
|
|
memory.getAll({ filters: { user_id: testUserId }, topK: -1 }),
|
|
).rejects.toThrow("Invalid topK");
|
|
});
|
|
|
|
it("should throw error when user_id contains internal whitespace", async () => {
|
|
await expect(
|
|
memory.getAll({ filters: { user_id: "user 123" } }),
|
|
).rejects.toThrow("Invalid user_id: cannot contain whitespace");
|
|
});
|
|
|
|
it("should accept user_id with leading/trailing whitespace (trimmed)", async () => {
|
|
const result = await memory.getAll({
|
|
filters: { user_id: " valid-user " },
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result.results).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("deleteAll() entity ID validation", () => {
|
|
it("should throw error when userId is whitespace-only", async () => {
|
|
await expect(memory.deleteAll({ userId: " " })).rejects.toThrow(
|
|
"Invalid userId",
|
|
);
|
|
});
|
|
|
|
it("should throw error when userId contains internal whitespace", async () => {
|
|
await expect(memory.deleteAll({ userId: "user 123" })).rejects.toThrow(
|
|
"Invalid userId: cannot contain whitespace",
|
|
);
|
|
});
|
|
|
|
it("should trim userId before listing memories", async () => {
|
|
const listSpy = jest.spyOn(memory["vectorStore"], "list");
|
|
listSpy.mockResolvedValue([[], null]);
|
|
|
|
await memory.deleteAll({ userId: " alice " });
|
|
|
|
expect(listSpy).toHaveBeenCalledWith(
|
|
{ user_id: "alice" },
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
});
|
|
});
|