Files
2026-05-30 14:16:04 -05:00

697 lines
18 KiB
TypeScript

import { beforeEach, describe, expect, it, jest } from "bun:test"
import { BmClient } from "./bm-client.ts"
const DEFAULT_PROJECT = "test-project"
function mcpResult(payload: unknown) {
return {
structuredContent: { result: payload },
content: [
{
type: "text",
text: JSON.stringify(payload),
},
],
}
}
function setConnected(client: BmClient, callTool: jest.Mock) {
;(client as any).client = {
callTool,
close: jest.fn().mockResolvedValue(undefined),
}
;(client as any).transport = {
close: jest.fn().mockResolvedValue(undefined),
}
}
describe("BmClient MCP behavior", () => {
let client: BmClient
beforeEach(() => {
client = new BmClient("/usr/local/bin/bm", DEFAULT_PROJECT)
})
it("readNote calls read_note with JSON output and no frontmatter by default", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
title: "t",
permalink: "p",
content: "body",
file_path: "notes/t.md",
frontmatter: null,
}),
)
setConnected(client, callTool)
const note = await client.readNote("t")
expect(callTool).toHaveBeenCalledWith({
name: "read_note",
arguments: {
identifier: "t",
include_frontmatter: false,
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(note.content).toBe("body")
})
it("readNote includes frontmatter when requested", async () => {
const raw = "---\ntitle: t\n---\n\nbody"
const callTool = jest.fn().mockResolvedValue(
mcpResult({
title: "t",
permalink: "p",
content: raw,
file_path: "notes/t.md",
frontmatter: { title: "t" },
}),
)
setConnected(client, callTool)
const note = await client.readNote("t", { includeFrontmatter: true })
expect(callTool).toHaveBeenCalledWith({
name: "read_note",
arguments: {
identifier: "t",
include_frontmatter: true,
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(note.content).toBe(raw)
expect(note.frontmatter).toEqual({ title: "t" })
})
it("writeNote calls write_note with JSON output", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
title: "Note",
permalink: "notes/note",
file_path: "notes/note.md",
checksum: "abc123",
action: "created",
}),
)
setConnected(client, callTool)
const result = await client.writeNote("Note", "hello", "notes")
expect(callTool).toHaveBeenCalledWith({
name: "write_note",
arguments: {
title: "Note",
content: "hello",
directory: "notes",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result.checksum).toBe("abc123")
expect(result.action).toBe("created")
})
it("writeNote passes overwrite flag when provided", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
title: "Note",
permalink: "notes/note",
file_path: "notes/note.md",
action: "updated",
}),
)
setConnected(client, callTool)
await client.writeNote("Note", "hello", "notes", undefined, true)
expect(callTool).toHaveBeenCalledWith({
name: "write_note",
arguments: {
title: "Note",
content: "hello",
directory: "notes",
output_format: "json",
project: DEFAULT_PROJECT,
overwrite: true,
},
})
})
it("writeNote throws NoteAlreadyExistsError on conflict response", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
title: "Existing",
permalink: "notes/existing",
file_path: null,
checksum: null,
action: "conflict",
error: "NOTE_ALREADY_EXISTS",
}),
)
setConnected(client, callTool)
await expect(
client.writeNote("Existing", "content", "notes"),
).rejects.toThrow("Note already exists")
})
it("editNote calls edit_note with MCP argument names", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
title: "t",
permalink: "p",
file_path: "notes/t.md",
operation: "find_replace",
checksum: "abc",
}),
)
setConnected(client, callTool)
const result = await client.editNote("t", "find_replace", "new", {
find_text: "old",
expected_replacements: 2,
})
expect(callTool).toHaveBeenCalledWith({
name: "edit_note",
arguments: {
identifier: "t",
operation: "find_replace",
content: "new",
find_text: "old",
section: undefined,
expected_replacements: 2,
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result.checksum).toBe("abc")
})
it("search calls search_notes with paging params", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
results: [
{
title: "x",
permalink: "x",
content: "c",
file_path: "notes/x.md",
score: 0.9,
},
],
}),
)
setConnected(client, callTool)
const results = await client.search("marketing strategy", 3)
expect(callTool).toHaveBeenCalledWith({
name: "search_notes",
arguments: {
query: "marketing strategy",
page: 1,
page_size: 3,
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(results).toHaveLength(1)
expect(results[0].title).toBe("x")
})
it("search passes metadata_filters, tags, and status to search_notes", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
results: [
{
title: "Auth Design",
permalink: "auth-design",
content: "OAuth spec",
file_path: "specs/auth-design.md",
score: 0.85,
},
],
}),
)
setConnected(client, callTool)
const results = await client.search("oauth", 5, "research", {
filters: { type: "spec", confidence: { $gt: 0.7 } },
tags: ["security"],
status: "in-progress",
})
expect(callTool).toHaveBeenCalledWith({
name: "search_notes",
arguments: {
query: "oauth",
page: 1,
page_size: 5,
output_format: "json",
project: "research",
metadata_filters: { type: "spec", confidence: { $gt: 0.7 } },
tags: ["security"],
status: "in-progress",
},
})
expect(results).toHaveLength(1)
expect(results[0].title).toBe("Auth Design")
})
it("search omits metadata args when not provided", async () => {
const callTool = jest.fn().mockResolvedValue(mcpResult({ results: [] }))
setConnected(client, callTool)
await client.search("test", 10)
expect(callTool).toHaveBeenCalledWith({
name: "search_notes",
arguments: {
query: "test",
page: 1,
page_size: 10,
output_format: "json",
project: DEFAULT_PROJECT,
},
})
})
it("buildContext calls build_context using output_format=json", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
results: [
{
primary_result: {
title: "x",
permalink: "x",
content: "body",
file_path: "notes/x.md",
},
observations: [],
related_results: [],
},
],
}),
)
setConnected(client, callTool)
const ctx = await client.buildContext("memory://notes/x", 2)
expect(callTool).toHaveBeenCalledWith({
name: "build_context",
arguments: {
url: "memory://notes/x",
depth: 2,
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(ctx.results).toHaveLength(1)
})
it("recentActivity calls recent_activity with JSON output", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult([
{
title: "x",
permalink: "x",
file_path: "notes/x.md",
created_at: "2026-01-01T00:00:00Z",
},
]),
)
setConnected(client, callTool)
const recent = await client.recentActivity("7d")
expect(callTool).toHaveBeenCalledWith({
name: "recent_activity",
arguments: {
timeframe: "7d",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(recent).toHaveLength(1)
})
it("listProjects calls list_memory_projects with JSON output", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
projects: [
{
name: "alpha",
path: "/tmp/alpha",
is_default: true,
},
],
}),
)
setConnected(client, callTool)
const projects = await client.listProjects()
expect(callTool).toHaveBeenCalledWith({
name: "list_memory_projects",
arguments: {
output_format: "json",
},
})
expect(projects[0].name).toBe("alpha")
})
it("listProjects filters workspace client-side without passing unsupported MCP args", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
projects: [
{
name: "alpha",
path: "/tmp/alpha",
workspace_name: "Team Alpha",
workspace_slug: "team-alpha",
workspace_tenant_id: "tenant-alpha",
},
{
name: "beta",
path: "/tmp/beta",
workspace_name: "Team Beta",
workspace_slug: "team-beta",
workspace_tenant_id: "tenant-beta",
},
],
}),
)
setConnected(client, callTool)
const projects = await client.listProjects("team-alpha")
expect(callTool).toHaveBeenCalledWith({
name: "list_memory_projects",
arguments: {
output_format: "json",
},
})
expect(projects.map((project) => project.name)).toEqual(["alpha"])
})
it("ensureProject calls create_memory_project in idempotent JSON mode", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
name: "test-project",
path: "/tmp/memory",
created: false,
already_exists: true,
}),
)
setConnected(client, callTool)
await client.ensureProject("/tmp/memory")
expect(callTool).toHaveBeenCalledWith({
name: "create_memory_project",
arguments: {
project_name: "test-project",
project_path: "/tmp/memory",
set_default: true,
output_format: "json",
},
})
})
it("deleteNote calls delete_note with JSON output", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
deleted: true,
title: "old-note",
permalink: "notes/old-note",
file_path: "notes/old-note.md",
}),
)
setConnected(client, callTool)
const result = await client.deleteNote("notes/old-note")
expect(callTool).toHaveBeenCalledWith({
name: "delete_note",
arguments: {
identifier: "notes/old-note",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result.file_path).toBe("notes/old-note.md")
})
it("schemaValidate calls schema_validate with JSON output", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
entity_type: "person",
total_notes: 3,
total_entities: 3,
valid_count: 3,
warning_count: 0,
error_count: 0,
results: [],
}),
)
setConnected(client, callTool)
const result = await client.schemaValidate("person")
expect(callTool).toHaveBeenCalledWith({
name: "schema_validate",
arguments: {
note_type: "person",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result.entity_type).toBe("person")
expect(result.valid_count).toBe(3)
})
it("schemaValidate passes identifier when provided", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
entity_type: null,
total_notes: 1,
total_entities: 1,
valid_count: 1,
warning_count: 0,
error_count: 0,
results: [],
}),
)
setConnected(client, callTool)
await client.schemaValidate(undefined, "notes/my-note")
expect(callTool).toHaveBeenCalledWith({
name: "schema_validate",
arguments: {
identifier: "notes/my-note",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
})
it("schemaInfer calls schema_infer with threshold", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
entity_type: "task",
notes_analyzed: 10,
field_frequencies: [],
suggested_schema: {},
suggested_required: [],
suggested_optional: [],
excluded: [],
}),
)
setConnected(client, callTool)
const result = await client.schemaInfer("task", 0.5)
expect(callTool).toHaveBeenCalledWith({
name: "schema_infer",
arguments: {
note_type: "task",
threshold: 0.5,
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result.notes_analyzed).toBe(10)
})
it("schemaDiff calls schema_diff with JSON output", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
entity_type: "person",
schema_found: true,
new_fields: [{ field: "phone", frequency: 0.6 }],
dropped_fields: [],
cardinality_changes: [],
}),
)
setConnected(client, callTool)
const result = await client.schemaDiff("person")
expect(callTool).toHaveBeenCalledWith({
name: "schema_diff",
arguments: {
note_type: "person",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result.new_fields).toHaveLength(1)
})
it("search with note_types and status filters (no query)", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
results: [
{
title: "Task 1",
permalink: "tasks/task-1",
content: "active task",
file_path: "tasks/task-1.md",
score: 0.9,
},
],
}),
)
setConnected(client, callTool)
const result = await client.search(undefined, 10, undefined, {
note_types: ["task"],
status: "active",
})
expect(callTool).toHaveBeenCalledWith({
name: "search_notes",
arguments: {
page: 1,
page_size: 10,
note_types: ["task"],
status: "active",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result).toHaveLength(1)
expect(result[0].title).toBe("Task 1")
})
it("moveNote calls move_note with destination_folder in a single MCP call", async () => {
const callTool = jest.fn().mockResolvedValue(
mcpResult({
moved: true,
title: "My Note",
permalink: "archive/my-note",
file_path: "archive/my-note.md",
source: "notes/my-note",
destination: "archive/my-note.md",
}),
)
setConnected(client, callTool)
const result = await client.moveNote("notes/my-note", "archive")
expect(callTool).toHaveBeenCalledTimes(1)
expect(callTool).toHaveBeenCalledWith({
name: "move_note",
arguments: {
identifier: "notes/my-note",
destination_folder: "archive",
output_format: "json",
project: DEFAULT_PROJECT,
},
})
expect(result.title).toBe("My Note")
expect(result.file_path).toBe("archive/my-note.md")
})
it("indexConversation surfaces non-not-found append errors without creating", async () => {
;(client as any).editNote = jest
.fn()
.mockRejectedValue(new Error("validation failed"))
;(client as any).writeNote = jest.fn()
await expect(
client.indexConversation(
"user message long enough",
"assistant reply long enough",
),
).rejects.toThrow("validation failed")
expect((client as any).writeNote).not.toHaveBeenCalled()
})
it("indexConversation creates fallback note only on note-not-found errors", async () => {
;(client as any).editNote = jest
.fn()
.mockRejectedValue(new Error("Entity not found"))
;(client as any).writeNote = jest.fn().mockResolvedValue({
title: "conversations",
permalink: "conversations",
content: "x",
file_path: "conversations/x.md",
})
await client.indexConversation(
"user message long enough",
"assistant reply long enough",
)
expect((client as any).writeNote).toHaveBeenCalledTimes(1)
const args = (client as any).writeNote.mock.calls[0]
expect(args[3]).toBeUndefined() // project
expect(args[4]).toBeUndefined() // overwrite
})
it("retries recoverable MCP failures with bounded attempts", async () => {
;(client as any).retryDelaysMs = [0, 0, 0]
const callTool = jest
.fn()
.mockRejectedValue(new Error("connection closed by peer"))
;(client as any).ensureConnected = jest.fn().mockResolvedValue({ callTool })
;(client as any).disconnectCurrent = jest.fn().mockResolvedValue(undefined)
;(client as any).client = { close: jest.fn().mockResolvedValue(undefined) }
;(client as any).transport = {
close: jest.fn().mockResolvedValue(undefined),
}
await expect(
(client as any).callToolRaw("search_notes", { query: "x" }),
).rejects.toThrow("BM MCP unavailable")
expect((client as any).ensureConnected).toHaveBeenCalledTimes(4)
expect((client as any).disconnectCurrent).toHaveBeenCalledTimes(4)
})
it("does not retry non-recoverable tool failures", async () => {
;(client as any).retryDelaysMs = [0, 0, 0]
const callTool = jest.fn().mockRejectedValue(new Error("invalid params"))
;(client as any).ensureConnected = jest.fn().mockResolvedValue({ callTool })
;(client as any).disconnectCurrent = jest.fn().mockResolvedValue(undefined)
await expect(
(client as any).callToolRaw("search_notes", { query: "x" }),
).rejects.toThrow("invalid params")
expect((client as any).ensureConnected).toHaveBeenCalledTimes(1)
expect((client as any).disconnectCurrent).toHaveBeenCalledTimes(0)
})
})