@abarcenas/ai-workflow-template 1.49.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/instructions/knowledgebase.instructions.md +2 -2
- package/.agents/instructions/learned-knowledge.instructions.md +47 -2
- package/.agents/instructions/memory-schema.instructions.md +1 -1
- package/.github/workflows/npm-publish.yml +69 -1
- package/.opencode/plugins/graphify.js +22 -0
- package/LICENSE +15 -0
- package/README.md +56 -22
- package/memory-bank/.vocabulary.json +3 -0
- package/opencode.mcp.example.json +13 -0
- package/package.json +20 -10
- package/scripts/bump-version.js +18 -2
- package/scripts/mcp/playwright-mcp-launcher.js +45 -2
- package/scripts/bump-version.test.js +0 -238
- package/scripts/knowledgebase-cli.test.js +0 -576
- package/scripts/knowledgebase-index.test.js +0 -759
- package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
- package/scripts/mcp-knowledgebase-server.test.js +0 -745
- package/scripts/mcp-memory-server.test.js +0 -398
- package/scripts/memory-cli.test.js +0 -297
- package/scripts/memory-index.test.js +0 -1173
- package/scripts/normalize-memory.test.js +0 -753
- package/scripts/setup/discover.test.js +0 -530
- package/scripts/setup/hooks.test.js +0 -722
- package/scripts/setup/husky-init.test.js +0 -311
- package/scripts/setup/index.test.js +0 -1021
- package/scripts/setup/knowledgebase.test.js +0 -352
- package/scripts/setup/prepare.test.js +0 -718
- package/scripts/setup/skills-phase.test.js +0 -1274
- package/scripts/setup/sync-phase.test.js +0 -373
- package/scripts/setup/ui.test.js +0 -579
- package/scripts/setup/utils.test.js +0 -586
- package/scripts/sync-unit.test.js +0 -1881
- package/scripts/sync.test.js +0 -472
- package/scripts/validate-memory-schema.test.js +0 -328
- package/scripts/vocab-sync.test.js +0 -902
|
@@ -1,745 +0,0 @@
|
|
|
1
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// mcp-knowledgebase-server.test.js — Unit tests for the MCP knowledgebase server
|
|
3
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
-
//
|
|
5
|
-
// The MCP server is a stdio process, so its tool-call handler is exported as
|
|
6
|
-
// `handleToolCall` for testability and the stdio bootstrap (main) is guarded to
|
|
7
|
-
// direct-run only. The core engine (./knowledgebase-index.js) is fully mocked
|
|
8
|
-
// so tests verify handler logic — registration order, guards, formatting, and
|
|
9
|
-
// graceful degradation — without a database or stdio connection.
|
|
10
|
-
//
|
|
11
|
-
// Covered scenarios (per plan T3 / TEST-01..08 plus degradation + integrity):
|
|
12
|
-
// 1. knowledgebase_index calls registerProject BEFORE upsertChunks with (projectId, projectId)
|
|
13
|
-
// 2. knowledgebase_index skips registerProject when chunks are empty (guard placement)
|
|
14
|
-
// 3. knowledgebase_index reads the default file when no content is provided
|
|
15
|
-
// 4. knowledgebase_index returns an error response when the default file is missing
|
|
16
|
-
// 5. knowledgebase_index returns formatted indexed/updated/skipped counts
|
|
17
|
-
// 6. knowledgebase_index validates projectId is required
|
|
18
|
-
// 6b. knowledgebase_index rejects whitespace-only projectId
|
|
19
|
-
// 6c. knowledgebase_index trims whitespace from projectId before downstream use
|
|
20
|
-
// 7. knowledgebase_search returns formatted results with similarity scores (regression)
|
|
21
|
-
// 8. knowledgebase_stats returns formatted stats (regression)
|
|
22
|
-
// 9. knowledgebase_search degrades gracefully when the pool/DATABASE_URL is unavailable
|
|
23
|
-
// 10. knowledgebase_list degrades gracefully when the pool/DATABASE_URL is unavailable
|
|
24
|
-
// 11. import integrity — registerProject is exported from knowledgebase-index.js (smoke)
|
|
25
|
-
// 12. knowledgebase_search "No results found" when the pool IS available (empty-kb branch)
|
|
26
|
-
// 13. knowledgebase_stats not-available message when zero counts AND no pool
|
|
27
|
-
// 14. knowledgebase_list formats indexed projects when projects exist
|
|
28
|
-
// 15. unknown tool name returns a caught error response (default branch + outer catch)
|
|
29
|
-
// 16. knowledgebase_index returns an error response when upsertChunks rejects
|
|
30
|
-
// 17. redactConnectionString FAILS CLOSED — unix-socket authority postgres://user:secret@/var/run/postgresql in an error message is redacted (no raw credentials)
|
|
31
|
-
// 18. redactConnectionString FAILS CLOSED — postgresql://user:secret@/tmp?host=/tmp variant is redacted (query string now also redacted → ?***)
|
|
32
|
-
// 19. redactConnectionString redacts EVERY postgres:// token in a message (not just the first)
|
|
33
|
-
// 20. knowledgebase_search drops a whitespace-only projectId (match-all, graceful degradation) — trimmed value passed to the engine
|
|
34
|
-
// 21. knowledgebase_search honors an explicit limit: 0 (nullish coalescing, not silently 5)
|
|
35
|
-
// 22. redactConnectionString redacts query-string params in URL tokens (belt-and-braces) — ?password=... does not survive
|
|
36
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
37
|
-
|
|
38
|
-
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
39
|
-
|
|
40
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
41
|
-
// Module mocks — core engine fully mocked; only readFileSync is stubbed in
|
|
42
|
-
// node:fs so the handler's default-file reads can be controlled hermetically.
|
|
43
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
44
|
-
|
|
45
|
-
vi.mock('./knowledgebase-index.js', () => ({
|
|
46
|
-
search: vi.fn(),
|
|
47
|
-
upsertChunks: vi.fn(),
|
|
48
|
-
chunkLearnedKnowledge: vi.fn(),
|
|
49
|
-
getStats: vi.fn(),
|
|
50
|
-
listProjects: vi.fn(),
|
|
51
|
-
registerProject: vi.fn(),
|
|
52
|
-
getPool: vi.fn(),
|
|
53
|
-
closePool: vi.fn(),
|
|
54
|
-
}))
|
|
55
|
-
|
|
56
|
-
vi.mock('node:fs', async () => {
|
|
57
|
-
const actual = await vi.importActual('node:fs')
|
|
58
|
-
return {
|
|
59
|
-
...actual,
|
|
60
|
-
readFileSync: vi.fn(),
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
65
|
-
// Fixtures
|
|
66
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
67
|
-
|
|
68
|
-
const DEFAULT_KB_FILE = '.agents/instructions/learned-knowledge.instructions.md'
|
|
69
|
-
|
|
70
|
-
const markdownWithSession = [
|
|
71
|
-
'# Previous Work Summary',
|
|
72
|
-
'',
|
|
73
|
-
'## Session: 2026-08-02 — MCP registerProject Fix',
|
|
74
|
-
'',
|
|
75
|
-
'**Pipeline:** fix',
|
|
76
|
-
'',
|
|
77
|
-
'**New knowledge:**',
|
|
78
|
-
'- registerProject must be called before upsertChunks',
|
|
79
|
-
'- The MCP server now registers the project row before inserting chunks',
|
|
80
|
-
'',
|
|
81
|
-
].join('\n')
|
|
82
|
-
|
|
83
|
-
const sampleChunks = [
|
|
84
|
-
{
|
|
85
|
-
project_id: 'test-project',
|
|
86
|
-
session_date: '2026-08-02',
|
|
87
|
-
content: 'registerProject must be called before upsertChunks',
|
|
88
|
-
content_hash: 'abc123',
|
|
89
|
-
},
|
|
90
|
-
]
|
|
91
|
-
|
|
92
|
-
const sampleResults = [
|
|
93
|
-
{
|
|
94
|
-
project_id: 'test-project',
|
|
95
|
-
session_date: '2026-08-01',
|
|
96
|
-
session_title: 'Session: 2026-08-01',
|
|
97
|
-
pipeline: 'test-pipeline',
|
|
98
|
-
content: 'Short test content for verification',
|
|
99
|
-
similarity: 0.87,
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
project_id: 'test-project',
|
|
103
|
-
session_date: '2026-07-31',
|
|
104
|
-
session_title: 'Session: 2026-07-31',
|
|
105
|
-
pipeline: 'test-pipeline',
|
|
106
|
-
content: 'Another chunk of knowledge for the second result row',
|
|
107
|
-
similarity: 0.72,
|
|
108
|
-
},
|
|
109
|
-
]
|
|
110
|
-
|
|
111
|
-
const sampleStats = {
|
|
112
|
-
total_projects: 2,
|
|
113
|
-
total_chunks: 23,
|
|
114
|
-
db_size: '128 kB',
|
|
115
|
-
last_sync: '2026-08-02T12:00:00Z',
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Build an MCP tool-call request object as the SDK would deliver it.
|
|
120
|
-
* @param {string} name - tool name
|
|
121
|
-
* @param {Record<string, any>} args - tool arguments
|
|
122
|
-
* @returns {{ params: { name: string, arguments: Record<string, any> } }}
|
|
123
|
-
*/
|
|
124
|
-
function buildRequest(name, args = {}) {
|
|
125
|
-
return { params: { name, arguments: args } }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
129
|
-
// Tests
|
|
130
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
131
|
-
|
|
132
|
-
describe('mcp-knowledgebase-server', () => {
|
|
133
|
-
let mod
|
|
134
|
-
let kb
|
|
135
|
-
let readFileSyncMock
|
|
136
|
-
|
|
137
|
-
beforeEach(async () => {
|
|
138
|
-
vi.resetModules()
|
|
139
|
-
vi.clearAllMocks()
|
|
140
|
-
mod = await import('./mcp-knowledgebase-server.js')
|
|
141
|
-
kb = await import('./knowledgebase-index.js')
|
|
142
|
-
readFileSyncMock = (await import('node:fs')).readFileSync
|
|
143
|
-
// Default: reading the file succeeds with the fixture. Individual tests
|
|
144
|
-
// override readFileSyncMock.mockImplementation when they need ENOENT.
|
|
145
|
-
readFileSyncMock.mockReturnValue(markdownWithSession)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
afterEach(() => {
|
|
149
|
-
vi.restoreAllMocks()
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
// ── TEST-01: registerProject is called before upsertChunks ─────────────
|
|
153
|
-
|
|
154
|
-
it('knowledgebase_index: calls registerProject before upsertChunks with (projectId, projectId)', async () => {
|
|
155
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
156
|
-
kb.registerProject.mockResolvedValue({
|
|
157
|
-
id: 'test-project',
|
|
158
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
159
|
-
})
|
|
160
|
-
kb.upsertChunks.mockResolvedValue({ inserted: 1, updated: 0, skipped: 0 })
|
|
161
|
-
|
|
162
|
-
const response = await mod.handleToolCall(
|
|
163
|
-
buildRequest('knowledgebase_index', {
|
|
164
|
-
projectId: 'test-project',
|
|
165
|
-
content: markdownWithSession,
|
|
166
|
-
}),
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
// registerProject called with the CLI convention (projectId, projectId)
|
|
170
|
-
expect(kb.registerProject).toHaveBeenCalledTimes(1)
|
|
171
|
-
expect(kb.registerProject).toHaveBeenCalledWith(
|
|
172
|
-
'test-project',
|
|
173
|
-
'test-project',
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
// upsertChunks still called with the parsed chunks after registration
|
|
177
|
-
expect(kb.upsertChunks).toHaveBeenCalledTimes(1)
|
|
178
|
-
expect(kb.upsertChunks).toHaveBeenCalledWith(sampleChunks)
|
|
179
|
-
|
|
180
|
-
// Order assertion: registerProject resolved BEFORE upsertChunks started
|
|
181
|
-
const registerOrder = kb.registerProject.mock.invocationCallOrder[0]
|
|
182
|
-
const upsertOrder = kb.upsertChunks.mock.invocationCallOrder[0]
|
|
183
|
-
expect(registerOrder).toBeLessThan(upsertOrder)
|
|
184
|
-
|
|
185
|
-
// Response shape delivered to the MCP client
|
|
186
|
-
expect(response.content).toBeInstanceOf(Array)
|
|
187
|
-
expect(response.content[0].type).toBe('text')
|
|
188
|
-
expect(response.isError).toBeUndefined()
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
// ── TEST-02: registerProject skipped when chunks are empty ─────────────
|
|
192
|
-
|
|
193
|
-
it('knowledgebase_index: does NOT call registerProject when chunks are empty', async () => {
|
|
194
|
-
kb.chunkLearnedKnowledge.mockReturnValue([])
|
|
195
|
-
|
|
196
|
-
const response = await mod.handleToolCall(
|
|
197
|
-
buildRequest('knowledgebase_index', {
|
|
198
|
-
projectId: 'test-project',
|
|
199
|
-
content: markdownWithSession,
|
|
200
|
-
}),
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
expect(kb.registerProject).not.toHaveBeenCalled()
|
|
204
|
-
expect(kb.upsertChunks).not.toHaveBeenCalled()
|
|
205
|
-
|
|
206
|
-
expect(response.content[0].text).toContain(
|
|
207
|
-
'No valid knowledge chunks found for project "test-project"',
|
|
208
|
-
)
|
|
209
|
-
expect(response.isError).toBeUndefined()
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
// ── TEST-03: default file read when no content provided ────────────────
|
|
213
|
-
|
|
214
|
-
it('knowledgebase_index: reads the default file when no content is provided', async () => {
|
|
215
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
216
|
-
kb.registerProject.mockResolvedValue({
|
|
217
|
-
id: 'test-project',
|
|
218
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
219
|
-
})
|
|
220
|
-
kb.upsertChunks.mockResolvedValue({ inserted: 1, updated: 0, skipped: 0 })
|
|
221
|
-
|
|
222
|
-
const response = await mod.handleToolCall(
|
|
223
|
-
buildRequest('knowledgebase_index', { projectId: 'test-project' }),
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
expect(readFileSyncMock).toHaveBeenCalledTimes(1)
|
|
227
|
-
expect(readFileSyncMock).toHaveBeenCalledWith(
|
|
228
|
-
expect.stringContaining(DEFAULT_KB_FILE),
|
|
229
|
-
'utf8',
|
|
230
|
-
)
|
|
231
|
-
// The file contents flow into chunkLearnedKnowledge
|
|
232
|
-
expect(kb.chunkLearnedKnowledge).toHaveBeenCalledWith(
|
|
233
|
-
markdownWithSession,
|
|
234
|
-
'test-project',
|
|
235
|
-
)
|
|
236
|
-
expect(response.isError).toBeUndefined()
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
// ── TEST-04: error response when default file is missing ───────────────
|
|
240
|
-
|
|
241
|
-
it('knowledgebase_index: returns error response when default file is missing and no content', async () => {
|
|
242
|
-
readFileSyncMock.mockImplementation(() => {
|
|
243
|
-
const err = new Error('ENOENT: no such file or directory, open ...')
|
|
244
|
-
err.code = 'ENOENT'
|
|
245
|
-
throw err
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
const response = await mod.handleToolCall(
|
|
249
|
-
buildRequest('knowledgebase_index', { projectId: 'test-project' }),
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
expect(response.isError).toBe(true)
|
|
253
|
-
expect(response.content[0].text).toContain('could not be read')
|
|
254
|
-
expect(response.content[0].text).toContain(DEFAULT_KB_FILE)
|
|
255
|
-
|
|
256
|
-
// No engine calls happen before the early error return
|
|
257
|
-
expect(kb.chunkLearnedKnowledge).not.toHaveBeenCalled()
|
|
258
|
-
expect(kb.registerProject).not.toHaveBeenCalled()
|
|
259
|
-
expect(kb.upsertChunks).not.toHaveBeenCalled()
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
// ── TEST-05: formatted counts response ─────────────────────────────────
|
|
263
|
-
|
|
264
|
-
it('knowledgebase_index: returns formatted indexed/updated/skipped counts', async () => {
|
|
265
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
266
|
-
kb.registerProject.mockResolvedValue({
|
|
267
|
-
id: 'test-project',
|
|
268
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
269
|
-
})
|
|
270
|
-
kb.upsertChunks.mockResolvedValue({ inserted: 3, updated: 2, skipped: 1 })
|
|
271
|
-
|
|
272
|
-
const response = await mod.handleToolCall(
|
|
273
|
-
buildRequest('knowledgebase_index', {
|
|
274
|
-
projectId: 'test-project',
|
|
275
|
-
content: markdownWithSession,
|
|
276
|
-
}),
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
expect(response.content[0].text).toBe(
|
|
280
|
-
'Indexed 3 chunks, updated 2, skipped 1 for project "test-project".',
|
|
281
|
-
)
|
|
282
|
-
expect(response.content[0].text).toContain('test-project')
|
|
283
|
-
expect(response.isError).toBeUndefined()
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
// ── TEST-06: projectId validation ──────────────────────────────────────
|
|
287
|
-
|
|
288
|
-
it('knowledgebase_index: returns error response when projectId is missing', async () => {
|
|
289
|
-
const response = await mod.handleToolCall(
|
|
290
|
-
buildRequest('knowledgebase_index', { content: markdownWithSession }),
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
expect(response.isError).toBe(true)
|
|
294
|
-
expect(response.content[0].text).toContain(
|
|
295
|
-
'Error: projectId is required for knowledgebase_index.',
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
// No engine calls happen for an invalid request
|
|
299
|
-
expect(kb.chunkLearnedKnowledge).not.toHaveBeenCalled()
|
|
300
|
-
expect(kb.registerProject).not.toHaveBeenCalled()
|
|
301
|
-
expect(kb.upsertChunks).not.toHaveBeenCalled()
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
// ── TEST-06b: whitespace-only projectId validation ────────────────────
|
|
305
|
-
|
|
306
|
-
it('knowledgebase_index: returns error response when projectId is whitespace-only', async () => {
|
|
307
|
-
const response = await mod.handleToolCall(
|
|
308
|
-
buildRequest('knowledgebase_index', {
|
|
309
|
-
projectId: ' ',
|
|
310
|
-
content: markdownWithSession,
|
|
311
|
-
}),
|
|
312
|
-
)
|
|
313
|
-
|
|
314
|
-
expect(response.isError).toBe(true)
|
|
315
|
-
expect(response.content[0].text).toContain(
|
|
316
|
-
'Error: projectId is required for knowledgebase_index.',
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
// No engine calls happen for an invalid request
|
|
320
|
-
expect(kb.chunkLearnedKnowledge).not.toHaveBeenCalled()
|
|
321
|
-
expect(kb.registerProject).not.toHaveBeenCalled()
|
|
322
|
-
expect(kb.upsertChunks).not.toHaveBeenCalled()
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
// ── TEST-06c: projectId trimmed before downstream use ─────────────────
|
|
326
|
-
|
|
327
|
-
it('knowledgebase_index: trims surrounding whitespace from projectId before use', async () => {
|
|
328
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
329
|
-
kb.registerProject.mockResolvedValue({
|
|
330
|
-
id: 'test-project',
|
|
331
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
332
|
-
})
|
|
333
|
-
kb.upsertChunks.mockResolvedValue({ inserted: 1, updated: 0, skipped: 0 })
|
|
334
|
-
|
|
335
|
-
const response = await mod.handleToolCall(
|
|
336
|
-
buildRequest('knowledgebase_index', {
|
|
337
|
-
projectId: ' test-project ',
|
|
338
|
-
content: markdownWithSession,
|
|
339
|
-
}),
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
// The trimmed value flows into chunking, registration, and the response
|
|
343
|
-
expect(kb.chunkLearnedKnowledge).toHaveBeenCalledWith(
|
|
344
|
-
markdownWithSession,
|
|
345
|
-
'test-project',
|
|
346
|
-
)
|
|
347
|
-
expect(kb.registerProject).toHaveBeenCalledWith('test-project', 'test-project')
|
|
348
|
-
expect(kb.upsertChunks).toHaveBeenCalledWith(sampleChunks)
|
|
349
|
-
expect(response.content[0].text).toContain(
|
|
350
|
-
'for project "test-project".',
|
|
351
|
-
)
|
|
352
|
-
expect(response.isError).toBeUndefined()
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
// ── TEST-07: knowledgebase_search formatting (regression) ──────────────
|
|
356
|
-
|
|
357
|
-
it('knowledgebase_search: returns formatted results with similarity scores', async () => {
|
|
358
|
-
kb.search.mockResolvedValue(sampleResults)
|
|
359
|
-
|
|
360
|
-
const response = await mod.handleToolCall(
|
|
361
|
-
buildRequest('knowledgebase_search', { query: 'test query' }),
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
expect(kb.search).toHaveBeenCalledWith('test query', {
|
|
365
|
-
project_id: undefined,
|
|
366
|
-
threshold: 0.1,
|
|
367
|
-
limit: 5,
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
expect(response.content[0].type).toBe('text')
|
|
371
|
-
expect(response.content[0].text).toContain('### Result 1 (similarity: 0.87)')
|
|
372
|
-
expect(response.content[0].text).toContain('### Result 2 (similarity: 0.72)')
|
|
373
|
-
expect(response.content[0].text).toContain('**Project:** `test-project`')
|
|
374
|
-
expect(response.content[0].text).toContain('**Date:** 2026-08-01')
|
|
375
|
-
expect(response.content[0].text).toContain('**Pipeline:** test-pipeline')
|
|
376
|
-
expect(response.content[0].text).toContain('---')
|
|
377
|
-
expect(response.isError).toBeUndefined()
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
// ── TEST-08: knowledgebase_stats formatting (regression) ───────────────
|
|
381
|
-
|
|
382
|
-
it('knowledgebase_stats: returns formatted stats', async () => {
|
|
383
|
-
kb.getStats.mockResolvedValue(sampleStats)
|
|
384
|
-
|
|
385
|
-
const response = await mod.handleToolCall(
|
|
386
|
-
buildRequest('knowledgebase_stats', {}),
|
|
387
|
-
)
|
|
388
|
-
|
|
389
|
-
expect(kb.getStats).toHaveBeenCalledTimes(1)
|
|
390
|
-
expect(response.content[0].type).toBe('text')
|
|
391
|
-
expect(response.content[0].text).toContain('**Knowledgebase Stats**')
|
|
392
|
-
expect(response.content[0].text).toContain('- Total projects: 2')
|
|
393
|
-
expect(response.content[0].text).toContain('- Total chunks: 23')
|
|
394
|
-
expect(response.content[0].text).toContain('- Database size: 128 kB')
|
|
395
|
-
expect(response.content[0].text).toContain('- Last sync: 2026-08-02T12:00:00Z')
|
|
396
|
-
expect(response.isError).toBeUndefined()
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
// ── TEST-09: graceful degradation — search with no pool ────────────────
|
|
400
|
-
|
|
401
|
-
it('knowledgebase_search: degrades gracefully when pool/DATABASE_URL unavailable (never throws)', async () => {
|
|
402
|
-
kb.search.mockResolvedValue([])
|
|
403
|
-
kb.getPool.mockResolvedValue(null)
|
|
404
|
-
|
|
405
|
-
const response = await mod.handleToolCall(
|
|
406
|
-
buildRequest('knowledgebase_search', { query: 'anything' }),
|
|
407
|
-
)
|
|
408
|
-
|
|
409
|
-
expect(response.content[0].text).toContain(
|
|
410
|
-
'Knowledgebase not available: DATABASE_URL is not configured.',
|
|
411
|
-
)
|
|
412
|
-
expect(response.isError).toBeUndefined()
|
|
413
|
-
expect(kb.getPool).toHaveBeenCalledTimes(1)
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
// ── TEST-10: graceful degradation — list with no pool ──────────────────
|
|
417
|
-
|
|
418
|
-
it('knowledgebase_list: degrades gracefully when pool/DATABASE_URL unavailable (never throws)', async () => {
|
|
419
|
-
kb.listProjects.mockResolvedValue([])
|
|
420
|
-
kb.getPool.mockResolvedValue(null)
|
|
421
|
-
|
|
422
|
-
const response = await mod.handleToolCall(
|
|
423
|
-
buildRequest('knowledgebase_list', {}),
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
expect(response.content[0].text).toContain(
|
|
427
|
-
'Knowledgebase not available: DATABASE_URL is not configured.',
|
|
428
|
-
)
|
|
429
|
-
expect(response.isError).toBeUndefined()
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
// ── TEST-11: import integrity smoke check ──────────────────────────────
|
|
433
|
-
|
|
434
|
-
it('import integrity: registerProject is exported from the real knowledgebase-index.js', async () => {
|
|
435
|
-
// Bypass the mock to smoke-check the real module's public surface
|
|
436
|
-
const real = await vi.importActual('./knowledgebase-index.js')
|
|
437
|
-
|
|
438
|
-
expect(typeof real.registerProject).toBe('function')
|
|
439
|
-
expect(typeof real.upsertChunks).toBe('function')
|
|
440
|
-
expect(typeof real.chunkLearnedKnowledge).toBe('function')
|
|
441
|
-
})
|
|
442
|
-
|
|
443
|
-
// ── TEST-12: search — empty results with an available pool ─────────────
|
|
444
|
-
|
|
445
|
-
it('knowledgebase_search: returns "No results found" when pool IS available', async () => {
|
|
446
|
-
// search() returns [] but the pool exists → the empty-KB branch (not the
|
|
447
|
-
// "DATABASE_URL not configured" branch) must be hit
|
|
448
|
-
kb.search.mockResolvedValue([])
|
|
449
|
-
kb.getPool.mockResolvedValue({})
|
|
450
|
-
|
|
451
|
-
const response = await mod.handleToolCall(
|
|
452
|
-
buildRequest('knowledgebase_search', { query: 'anything' }),
|
|
453
|
-
)
|
|
454
|
-
|
|
455
|
-
expect(response.content[0].text).toBe(
|
|
456
|
-
'No results found. The knowledgebase may be empty.',
|
|
457
|
-
)
|
|
458
|
-
expect(response.isError).toBeUndefined()
|
|
459
|
-
expect(kb.getPool).toHaveBeenCalledTimes(1)
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
// ── TEST-13: stats — zero counts with no pool ──────────────────────────
|
|
463
|
-
|
|
464
|
-
it('knowledgebase_stats: returns not-available message when zero counts AND no pool', async () => {
|
|
465
|
-
kb.getStats.mockResolvedValue({
|
|
466
|
-
total_projects: 0,
|
|
467
|
-
total_chunks: 0,
|
|
468
|
-
db_size: '0 B',
|
|
469
|
-
last_sync: null,
|
|
470
|
-
})
|
|
471
|
-
kb.getPool.mockResolvedValue(null)
|
|
472
|
-
|
|
473
|
-
const response = await mod.handleToolCall(
|
|
474
|
-
buildRequest('knowledgebase_stats', {}),
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
expect(response.content[0].text).toContain(
|
|
478
|
-
'Knowledgebase not available: DATABASE_URL is not configured.',
|
|
479
|
-
)
|
|
480
|
-
expect(response.isError).toBeUndefined()
|
|
481
|
-
})
|
|
482
|
-
|
|
483
|
-
// ── TEST-14: list — formatted projects when projects exist ─────────────
|
|
484
|
-
|
|
485
|
-
it('knowledgebase_list: formats indexed projects with chunk counts', async () => {
|
|
486
|
-
kb.listProjects.mockResolvedValue([
|
|
487
|
-
{
|
|
488
|
-
project_id: 'project-a',
|
|
489
|
-
name: 'Project A',
|
|
490
|
-
chunk_count: 5,
|
|
491
|
-
last_indexed: '2026-08-02T00:00:00Z',
|
|
492
|
-
},
|
|
493
|
-
{
|
|
494
|
-
project_id: 'project-b',
|
|
495
|
-
name: null, // exercises the name || project_id fallback
|
|
496
|
-
chunk_count: 0,
|
|
497
|
-
last_indexed: null, // exercises the last_indexed || 'Never' fallback
|
|
498
|
-
},
|
|
499
|
-
])
|
|
500
|
-
|
|
501
|
-
const response = await mod.handleToolCall(
|
|
502
|
-
buildRequest('knowledgebase_list', {}),
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
expect(response.content[0].type).toBe('text')
|
|
506
|
-
expect(response.content[0].text).toContain('**Indexed Projects:**')
|
|
507
|
-
expect(response.content[0].text).toContain(
|
|
508
|
-
'- Project A (5 chunks, last indexed: 2026-08-02T00:00:00Z)',
|
|
509
|
-
)
|
|
510
|
-
expect(response.content[0].text).toContain(
|
|
511
|
-
'- project-b (0 chunks, last indexed: Never)',
|
|
512
|
-
)
|
|
513
|
-
expect(response.isError).toBeUndefined()
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
// ── TEST-15: unknown tool — outer catch returns error response ─────────
|
|
517
|
-
|
|
518
|
-
it('unknown tool: returns a caught error response (default branch)', async () => {
|
|
519
|
-
const response = await mod.handleToolCall(
|
|
520
|
-
buildRequest('knowledgebase_bogus', {}),
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
expect(response.isError).toBe(true)
|
|
524
|
-
expect(response.content[0].text).toBe(
|
|
525
|
-
'Error: Unknown tool: knowledgebase_bogus',
|
|
526
|
-
)
|
|
527
|
-
// The default branch throws — no engine function should have been touched
|
|
528
|
-
expect(kb.search).not.toHaveBeenCalled()
|
|
529
|
-
expect(kb.upsertChunks).not.toHaveBeenCalled()
|
|
530
|
-
})
|
|
531
|
-
|
|
532
|
-
// ── TEST-16: index — error path when upsertChunks rejects ──────────────
|
|
533
|
-
|
|
534
|
-
it('knowledgebase_index: returns error response when upsertChunks fails', async () => {
|
|
535
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
536
|
-
kb.registerProject.mockResolvedValue({
|
|
537
|
-
id: 'test-project',
|
|
538
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
539
|
-
})
|
|
540
|
-
kb.upsertChunks.mockRejectedValue(new Error('DB connection failed'))
|
|
541
|
-
|
|
542
|
-
const response = await mod.handleToolCall(
|
|
543
|
-
buildRequest('knowledgebase_index', {
|
|
544
|
-
projectId: 'test-project',
|
|
545
|
-
content: markdownWithSession,
|
|
546
|
-
}),
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
// registerProject still ran before the failure (the fix order is preserved)
|
|
550
|
-
expect(kb.registerProject).toHaveBeenCalledTimes(1)
|
|
551
|
-
expect(kb.upsertChunks).toHaveBeenCalledTimes(1)
|
|
552
|
-
expect(response.isError).toBe(true)
|
|
553
|
-
expect(response.content[0].text).toContain(
|
|
554
|
-
'Error: DB connection failed',
|
|
555
|
-
)
|
|
556
|
-
})
|
|
557
|
-
|
|
558
|
-
// ── TEST-17: redactConnectionString FAILS CLOSED — unix-socket authority ─
|
|
559
|
-
|
|
560
|
-
it('redactConnectionString: redacts unix-socket authority postgres://user:secret@/var/run/postgresql (fail-closed)', async () => {
|
|
561
|
-
// `new URL()` throws on this libpq bare-authority form — the helper must
|
|
562
|
-
// still strip the credentials instead of returning the message unchanged.
|
|
563
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
564
|
-
kb.registerProject.mockResolvedValue({
|
|
565
|
-
id: 'test-project',
|
|
566
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
567
|
-
})
|
|
568
|
-
kb.upsertChunks.mockRejectedValue(
|
|
569
|
-
new Error('connect ECONNREFUSED postgres://user:secret@/var/run/postgresql'),
|
|
570
|
-
)
|
|
571
|
-
|
|
572
|
-
const response = await mod.handleToolCall(
|
|
573
|
-
buildRequest('knowledgebase_index', {
|
|
574
|
-
projectId: 'test-project',
|
|
575
|
-
content: markdownWithSession,
|
|
576
|
-
}),
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
expect(response.isError).toBe(true)
|
|
580
|
-
// Credentials redacted, host/path preserved
|
|
581
|
-
expect(response.content[0].text).toContain(
|
|
582
|
-
'postgres://***@/var/run/postgresql',
|
|
583
|
-
)
|
|
584
|
-
// No raw credentials anywhere in the error text
|
|
585
|
-
expect(response.content[0].text).not.toContain('user:secret')
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
// ── TEST-18: redactConnectionString FAILS CLOSED — ?host= variant ───────
|
|
589
|
-
|
|
590
|
-
it('redactConnectionString: redacts postgresql://user:secret@/tmp?host=/tmp (fail-closed)', async () => {
|
|
591
|
-
// The alternate unix-socket form (`postgresql://user:secret@/tmp?host=/tmp`)
|
|
592
|
-
// is also unparseable by `new URL()` — credentials must not leak, and the
|
|
593
|
-
// query string is redacted too (→ ?***) as belt-and-braces.
|
|
594
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
595
|
-
kb.registerProject.mockResolvedValue({
|
|
596
|
-
id: 'test-project',
|
|
597
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
598
|
-
})
|
|
599
|
-
kb.upsertChunks.mockRejectedValue(
|
|
600
|
-
new Error('connect ECONNREFUSED postgresql://user:secret@/tmp?host=/tmp'),
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
const response = await mod.handleToolCall(
|
|
604
|
-
buildRequest('knowledgebase_index', {
|
|
605
|
-
projectId: 'test-project',
|
|
606
|
-
content: markdownWithSession,
|
|
607
|
-
}),
|
|
608
|
-
)
|
|
609
|
-
|
|
610
|
-
expect(response.isError).toBe(true)
|
|
611
|
-
expect(response.content[0].text).toContain(
|
|
612
|
-
'postgresql://***@/tmp?***',
|
|
613
|
-
)
|
|
614
|
-
expect(response.content[0].text).not.toContain('user:secret')
|
|
615
|
-
expect(response.content[0].text).not.toContain('host=/tmp')
|
|
616
|
-
})
|
|
617
|
-
|
|
618
|
-
// ── TEST-19: redactConnectionString redacts EVERY token ─────────────────
|
|
619
|
-
|
|
620
|
-
it('redactConnectionString: redacts EVERY postgres:// token in a message', async () => {
|
|
621
|
-
// A message containing multiple connection-string tokens must have all of
|
|
622
|
-
// them redacted, not just the first (replace-all strategy).
|
|
623
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
624
|
-
kb.registerProject.mockResolvedValue({
|
|
625
|
-
id: 'test-project',
|
|
626
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
627
|
-
})
|
|
628
|
-
kb.upsertChunks.mockRejectedValue(
|
|
629
|
-
new Error(
|
|
630
|
-
'primary postgres://u1:p1@host1/db1 secondary postgres://u2:p2@host2/db2',
|
|
631
|
-
),
|
|
632
|
-
)
|
|
633
|
-
|
|
634
|
-
const response = await mod.handleToolCall(
|
|
635
|
-
buildRequest('knowledgebase_index', {
|
|
636
|
-
projectId: 'test-project',
|
|
637
|
-
content: markdownWithSession,
|
|
638
|
-
}),
|
|
639
|
-
)
|
|
640
|
-
|
|
641
|
-
expect(response.isError).toBe(true)
|
|
642
|
-
expect(response.content[0].text).toContain('postgres://***@host1/db1')
|
|
643
|
-
expect(response.content[0].text).toContain('postgres://***@host2/db2')
|
|
644
|
-
expect(response.content[0].text).not.toContain('u1:p1')
|
|
645
|
-
expect(response.content[0].text).not.toContain('u2:p2')
|
|
646
|
-
})
|
|
647
|
-
|
|
648
|
-
// ── TEST-20: search — whitespace-only projectId is dropped (match-all) ───
|
|
649
|
-
|
|
650
|
-
it('knowledgebase_search: drops a whitespace-only projectId (match-all, graceful)', async () => {
|
|
651
|
-
// A whitespace-only projectId is not a meaningful filter — it is dropped
|
|
652
|
-
// to match-all (undefined), mirroring the INDEX handler's trim/validate
|
|
653
|
-
// parity while staying graceful (no hard error for a read-only filter).
|
|
654
|
-
kb.search.mockResolvedValue(sampleResults)
|
|
655
|
-
|
|
656
|
-
const response = await mod.handleToolCall(
|
|
657
|
-
buildRequest('knowledgebase_search', {
|
|
658
|
-
query: 'test query',
|
|
659
|
-
projectId: ' ',
|
|
660
|
-
}),
|
|
661
|
-
)
|
|
662
|
-
|
|
663
|
-
expect(kb.search).toHaveBeenCalledWith('test query', {
|
|
664
|
-
project_id: undefined,
|
|
665
|
-
threshold: 0.1,
|
|
666
|
-
limit: 5,
|
|
667
|
-
})
|
|
668
|
-
expect(response.isError).toBeUndefined()
|
|
669
|
-
})
|
|
670
|
-
|
|
671
|
-
// ── TEST-20b: search — surrounding whitespace trimmed from projectId ─────
|
|
672
|
-
|
|
673
|
-
it('knowledgebase_search: trims surrounding whitespace from projectId before filtering', async () => {
|
|
674
|
-
kb.search.mockResolvedValue(sampleResults)
|
|
675
|
-
|
|
676
|
-
const response = await mod.handleToolCall(
|
|
677
|
-
buildRequest('knowledgebase_search', {
|
|
678
|
-
query: 'test query',
|
|
679
|
-
projectId: ' test-project ',
|
|
680
|
-
}),
|
|
681
|
-
)
|
|
682
|
-
|
|
683
|
-
expect(kb.search).toHaveBeenCalledWith('test query', {
|
|
684
|
-
project_id: 'test-project',
|
|
685
|
-
threshold: 0.1,
|
|
686
|
-
limit: 5,
|
|
687
|
-
})
|
|
688
|
-
expect(response.isError).toBeUndefined()
|
|
689
|
-
})
|
|
690
|
-
|
|
691
|
-
// ── TEST-21: search — explicit limit 0 is honored (?? not ||) ────────────
|
|
692
|
-
|
|
693
|
-
it('knowledgebase_search: honors an explicit limit of 0 (nullish coalescing)', async () => {
|
|
694
|
-
// `args.limit || 5` would silently coerce an explicit 0 to 5; `?? 5`
|
|
695
|
-
// preserves it so the engine receives limit: 0 (LIMIT 0).
|
|
696
|
-
kb.search.mockResolvedValue(sampleResults)
|
|
697
|
-
|
|
698
|
-
const response = await mod.handleToolCall(
|
|
699
|
-
buildRequest('knowledgebase_search', {
|
|
700
|
-
query: 'test query',
|
|
701
|
-
limit: 0,
|
|
702
|
-
}),
|
|
703
|
-
)
|
|
704
|
-
|
|
705
|
-
expect(kb.search).toHaveBeenCalledWith('test query', {
|
|
706
|
-
project_id: undefined,
|
|
707
|
-
threshold: 0.1,
|
|
708
|
-
limit: 0,
|
|
709
|
-
})
|
|
710
|
-
expect(response.isError).toBeUndefined()
|
|
711
|
-
})
|
|
712
|
-
|
|
713
|
-
// ── TEST-22: redactConnectionString redacts query-string params ──────────
|
|
714
|
-
|
|
715
|
-
it('redactConnectionString: redacts query-string params in URL tokens (belt-and-braces)', async () => {
|
|
716
|
-
// A hypothetical ?password=... query param in a connection string must not
|
|
717
|
-
// survive redaction — the entire query string is replaced with ?***.
|
|
718
|
-
kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
|
|
719
|
-
kb.registerProject.mockResolvedValue({
|
|
720
|
-
id: 'test-project',
|
|
721
|
-
first_indexed_at: '2026-08-02T00:00:00Z',
|
|
722
|
-
})
|
|
723
|
-
kb.upsertChunks.mockRejectedValue(
|
|
724
|
-
new Error(
|
|
725
|
-
'connect ECONNREFUSED postgres://user:secret@host:5432/kb?password=hunter2',
|
|
726
|
-
),
|
|
727
|
-
)
|
|
728
|
-
|
|
729
|
-
const response = await mod.handleToolCall(
|
|
730
|
-
buildRequest('knowledgebase_index', {
|
|
731
|
-
projectId: 'test-project',
|
|
732
|
-
content: markdownWithSession,
|
|
733
|
-
}),
|
|
734
|
-
)
|
|
735
|
-
|
|
736
|
-
expect(response.isError).toBe(true)
|
|
737
|
-
// userinfo redacted and host/path preserved
|
|
738
|
-
expect(response.content[0].text).toContain(
|
|
739
|
-
'postgres://***@host:5432/kb?***',
|
|
740
|
-
)
|
|
741
|
-
// neither the password value nor the query key survives
|
|
742
|
-
expect(response.content[0].text).not.toContain('hunter2')
|
|
743
|
-
expect(response.content[0].text).not.toContain('password=')
|
|
744
|
-
})
|
|
745
|
-
})
|