@abarcenas/ai-workflow-template 1.34.0 → 1.38.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 +238 -0
- package/.agents/instructions/learned-knowledge.instructions.md +71 -0
- package/.husky/post-commit +10 -0
- package/.husky/post-merge +0 -1
- package/.opencode/agents/architect.agent.md +1 -0
- package/.opencode/agents/coder.agent.md +1 -0
- package/.opencode/agents/deployer.agent.md +1 -0
- package/.opencode/agents/designer.agent.md +1 -0
- package/.opencode/agents/e2e-tester.agent.md +1 -0
- package/.opencode/agents/implementer.agent.md +1 -0
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +1 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +1 -0
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +1 -0
- package/.opencode/agents/researcher.agent.md +1 -0
- package/.opencode/agents/reviewer.agent.md +1 -0
- package/.opencode/agents/tracker.agent.md +2 -0
- package/.opencode/agents/unit-tester.agent.md +1 -0
- package/README.md +1 -1
- package/memory-bank/.vocabulary.json +8 -2
- package/package.json +12 -6
- package/scripts/knowledgebase-cli.js +228 -0
- package/scripts/knowledgebase-cli.test.js +576 -0
- package/scripts/knowledgebase-index.js +780 -0
- package/scripts/knowledgebase-index.test.js +759 -0
- package/scripts/knowledgebase-init.sql +193 -0
- package/scripts/mcp-knowledgebase-server.js +330 -0
- package/scripts/setup/constants.js +36 -3
- package/scripts/setup/hooks.js +2 -2
- package/scripts/setup/hooks.test.js +13 -5
- package/scripts/setup/index.js +29 -2
- package/scripts/setup/index.test.js +36 -1
- package/scripts/setup/knowledgebase.js +204 -0
- package/scripts/setup/knowledgebase.test.js +248 -0
- package/scripts/sync.js +40 -6
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// knowledgebase-index.test.js — Unit tests for the core knowledgebase engine
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Tests chunking utility, graceful degradation (DATABASE_URL unset),
|
|
6
|
+
// embedding generation, CRUD operations, search, and schema management.
|
|
7
|
+
//
|
|
8
|
+
// All external dependencies (pg, pgvector, @xenova/transformers) are fully
|
|
9
|
+
// mocked so tests are fast, isolated, and never touch a real database.
|
|
10
|
+
//
|
|
11
|
+
// Covered scenarios (≥16, per plan T15):
|
|
12
|
+
// 1. chunkLearnedKnowledge — 3 session blocks with correct metadata
|
|
13
|
+
// 2. chunkLearnedKnowledge — empty markdown string
|
|
14
|
+
// 3. chunkLearnedKnowledge — no session blocks
|
|
15
|
+
// 4. chunkLearnedKnowledge — no projectId (invalid)
|
|
16
|
+
// 5. chunkLearnedKnowledge — sessions with empty "New knowledge"
|
|
17
|
+
// 6. getPool() returns null when DATABASE_URL is unset
|
|
18
|
+
// 7. getPool() returns pool when DATABASE_URL is set (config validated)
|
|
19
|
+
// 8. getPool() singleton — same instance reused
|
|
20
|
+
// 9. embed() returns Float32Array(384)
|
|
21
|
+
// 10. embed() calls transformers pipeline with correct args
|
|
22
|
+
// 11. registerProject() inserts via INSERT ... ON CONFLICT
|
|
23
|
+
// 12. registerProject() degrades gracefully on failure
|
|
24
|
+
// 13. upsertChunks() returns inserted counts
|
|
25
|
+
// 14. upsertChunks() returns updated counts
|
|
26
|
+
// 15. upsertChunks() handles unique violation (skipped)
|
|
27
|
+
// 16. upsertChunks() returns zeros for empty/null chunks
|
|
28
|
+
// 17. upsertChunks() passes embedding as query param
|
|
29
|
+
// 18. search() returns ranked results with similarity scores
|
|
30
|
+
// 19. search() filters by project_id
|
|
31
|
+
// 20. search() returns [] on query failure
|
|
32
|
+
// 21. getStats() returns aggregated stats
|
|
33
|
+
// 22. getStats() returns zeros on failure
|
|
34
|
+
// 23. listProjects() returns project array with chunk counts
|
|
35
|
+
// 24. listProjects() returns [] on failure
|
|
36
|
+
// 25. ensureSchema() creates extension + tables + indexes
|
|
37
|
+
// 26. ensureSchema() continues after CREATE EXTENSION failure
|
|
38
|
+
// 27. ensureSchema() continues after HNSW index failure
|
|
39
|
+
// 28. closePool() calls pool.end() and resets state
|
|
40
|
+
// 29. closePool() is safe when pool never created
|
|
41
|
+
// 30. All graceful degradation paths (7 functions)
|
|
42
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
45
|
+
|
|
46
|
+
// ── Module mocks ───────────────────────────────────────────────────────────
|
|
47
|
+
// vi.mock is hoisted above all imports by Vitest. These factories run once.
|
|
48
|
+
// The objects they create are shared across all test re-imports via
|
|
49
|
+
// vi.resetModules(). Tests access the mock objects by importing the mocked
|
|
50
|
+
// modules directly with static imports (which are also hoisted below).
|
|
51
|
+
|
|
52
|
+
vi.mock('pg', () => {
|
|
53
|
+
const pool = {
|
|
54
|
+
query: vi.fn(),
|
|
55
|
+
end: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
on: vi.fn(),
|
|
57
|
+
}
|
|
58
|
+
// Regular function (not arrow) so `new Pool(...)` works.
|
|
59
|
+
// Returns the same shared pool object on every call.
|
|
60
|
+
const MockPool = vi.fn(function () {
|
|
61
|
+
return pool
|
|
62
|
+
})
|
|
63
|
+
return {
|
|
64
|
+
default: { Pool: MockPool },
|
|
65
|
+
Pool: MockPool,
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
vi.mock('pgvector', () => ({
|
|
70
|
+
default: {
|
|
71
|
+
toSql: vi.fn((v) => v),
|
|
72
|
+
fromSql: vi.fn((v) => v),
|
|
73
|
+
},
|
|
74
|
+
toSql: vi.fn((v) => v),
|
|
75
|
+
fromSql: vi.fn((v) => v),
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
vi.mock('@xenova/transformers', () => {
|
|
79
|
+
const mockModel = vi
|
|
80
|
+
.fn()
|
|
81
|
+
.mockResolvedValue({ data: new Float32Array(384).fill(0.1) })
|
|
82
|
+
const mockPipeline = vi.fn(() => mockModel)
|
|
83
|
+
return { pipeline: mockPipeline }
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ── Import mocked modules for test access ──────────────────────────────────
|
|
87
|
+
// These reuse the same mock objects created by the vi.mock factories above.
|
|
88
|
+
// Pool() always returns the shared mockPool, so tests can grab the reference.
|
|
89
|
+
|
|
90
|
+
import { Pool } from 'pg'
|
|
91
|
+
import { pipeline as mockPipeline } from '@xenova/transformers'
|
|
92
|
+
|
|
93
|
+
/** Reference to the shared mock pool object used by the module under test */
|
|
94
|
+
const mockPool = Pool()
|
|
95
|
+
|
|
96
|
+
// ── Test helpers ───────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** Module under test — reassigned in each beforeEach via vi.resetModules() */
|
|
99
|
+
let mod
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Creates a mock learned-knowledge.instructions.md string with 3 session
|
|
103
|
+
* blocks. Includes "What went well:" after the first session to verify that
|
|
104
|
+
* only "New knowledge:" bullet content is extracted.
|
|
105
|
+
*/
|
|
106
|
+
function createMockMarkdown() {
|
|
107
|
+
return [
|
|
108
|
+
'# Previous Work Summary',
|
|
109
|
+
'',
|
|
110
|
+
'## Session: 2026-07-20 — Initial Setup',
|
|
111
|
+
'',
|
|
112
|
+
'**Pipeline:** setup',
|
|
113
|
+
'**Coverage:** 85%',
|
|
114
|
+
'**TDD Iterations:** 3',
|
|
115
|
+
'',
|
|
116
|
+
'**New knowledge:**',
|
|
117
|
+
'- The `getPool()` function lazily initialises the connection pool',
|
|
118
|
+
'- All functions gracefully return empty/zero when DATABASE_URL is unset',
|
|
119
|
+
'- Dynamic import with try/catch enables optional dependencies pattern',
|
|
120
|
+
'',
|
|
121
|
+
'**What went well:**',
|
|
122
|
+
'- Fast iteration with mock-based tests',
|
|
123
|
+
'',
|
|
124
|
+
'## Session: 2026-07-22 — Feature Implementation',
|
|
125
|
+
'',
|
|
126
|
+
'**Pipeline:** feature',
|
|
127
|
+
'**Coverage:** 92%',
|
|
128
|
+
'**TDD Iterations:** 5',
|
|
129
|
+
'',
|
|
130
|
+
'**New knowledge:**',
|
|
131
|
+
'- Mocking dynamic imports requires `vi.resetModules()` for fresh state',
|
|
132
|
+
'- Content hash idempotency prevents duplicate indexing',
|
|
133
|
+
'',
|
|
134
|
+
'## Session: 2026-07-25 — Bug Fixes',
|
|
135
|
+
'',
|
|
136
|
+
'**Pipeline:** fix',
|
|
137
|
+
'**Coverage:** 78%',
|
|
138
|
+
'',
|
|
139
|
+
'**New knowledge:**',
|
|
140
|
+
'- Connection pool errors are handled gracefully with console.warn',
|
|
141
|
+
'',
|
|
142
|
+
].join('\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Creates a fake search result row.
|
|
147
|
+
* @param {object} overrides
|
|
148
|
+
* @returns {object}
|
|
149
|
+
*/
|
|
150
|
+
function makeChunkRow(overrides = {}) {
|
|
151
|
+
return {
|
|
152
|
+
project_id: 'test-project',
|
|
153
|
+
session_date: '2026-07-20',
|
|
154
|
+
session_title: 'Session: 2026-07-20 — Initial Setup',
|
|
155
|
+
pipeline: 'setup',
|
|
156
|
+
content: 'Some chunk content',
|
|
157
|
+
similarity: 0.87,
|
|
158
|
+
...overrides,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Cleanup between test groups ───────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
afterEach(() => {
|
|
165
|
+
delete process.env.DATABASE_URL
|
|
166
|
+
// Restore any overridden mock implementations (e.g. the embed test
|
|
167
|
+
// that temporarily sets mockPipeline.mockReturnValue(null))
|
|
168
|
+
vi.restoreAllMocks()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
172
|
+
// chunkLearnedKnowledge — pure function, no external dependencies
|
|
173
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
174
|
+
|
|
175
|
+
describe('chunkLearnedKnowledge', () => {
|
|
176
|
+
beforeEach(async () => {
|
|
177
|
+
vi.resetModules()
|
|
178
|
+
vi.clearAllMocks()
|
|
179
|
+
mod = await import('./knowledgebase-index.js')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('parses 3 session blocks and returns correct chunks with metadata', () => {
|
|
183
|
+
const markdown = createMockMarkdown()
|
|
184
|
+
const chunks = mod.chunkLearnedKnowledge(markdown, 'test-project')
|
|
185
|
+
|
|
186
|
+
expect(chunks).toHaveLength(3)
|
|
187
|
+
|
|
188
|
+
// ── Session 1 ──
|
|
189
|
+
expect(chunks[0].project_id).toBe('test-project')
|
|
190
|
+
expect(chunks[0].session_date).toBe('2026-07-20')
|
|
191
|
+
expect(chunks[0].session_title).toBe(
|
|
192
|
+
'Session: 2026-07-20 — Initial Setup',
|
|
193
|
+
)
|
|
194
|
+
expect(chunks[0].pipeline).toBe('setup')
|
|
195
|
+
expect(chunks[0].coverage).toBe('85%')
|
|
196
|
+
expect(chunks[0].tdd_iterations).toBe(3)
|
|
197
|
+
expect(chunks[0].content).toContain('getPool()')
|
|
198
|
+
expect(chunks[0].content).toContain('Dynamic import with try/catch')
|
|
199
|
+
// "What went well" must NOT be included in "New knowledge" content
|
|
200
|
+
expect(chunks[0].content).not.toContain('What went well')
|
|
201
|
+
expect(chunks[0].content).not.toContain('Fast iteration')
|
|
202
|
+
// content_hash is SHA-256 = 64 hex chars
|
|
203
|
+
expect(chunks[0].content_hash).toMatch(/^[a-f0-9]{64}$/)
|
|
204
|
+
|
|
205
|
+
// ── Session 2 ──
|
|
206
|
+
expect(chunks[1].session_date).toBe('2026-07-22')
|
|
207
|
+
expect(chunks[1].session_title).toBe(
|
|
208
|
+
'Session: 2026-07-22 — Feature Implementation',
|
|
209
|
+
)
|
|
210
|
+
expect(chunks[1].pipeline).toBe('feature')
|
|
211
|
+
expect(chunks[1].coverage).toBe('92%')
|
|
212
|
+
expect(chunks[1].tdd_iterations).toBe(5)
|
|
213
|
+
expect(chunks[1].content).toContain('vi.resetModules()')
|
|
214
|
+
expect(chunks[1].content).toContain('Content hash idempotency')
|
|
215
|
+
expect(chunks[1].content_hash).toMatch(/^[a-f0-9]{64}$/)
|
|
216
|
+
|
|
217
|
+
// ── Session 3 ──
|
|
218
|
+
expect(chunks[2].session_date).toBe('2026-07-25')
|
|
219
|
+
expect(chunks[2].session_title).toBe('Session: 2026-07-25 — Bug Fixes')
|
|
220
|
+
expect(chunks[2].pipeline).toBe('fix')
|
|
221
|
+
expect(chunks[2].coverage).toBe('78%')
|
|
222
|
+
expect(chunks[2].tdd_iterations).toBe(0) // defaults to 0 when missing
|
|
223
|
+
expect(chunks[2].content).toContain('Connection pool errors')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('returns empty array for empty markdown string', () => {
|
|
227
|
+
expect(mod.chunkLearnedKnowledge('', 'test-project')).toEqual([])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('returns empty array for markdown with no ## Session: blocks', () => {
|
|
231
|
+
const md = '# Just a header\n\nSome plain text without sessions.\n'
|
|
232
|
+
expect(mod.chunkLearnedKnowledge(md, 'test-project')).toEqual([])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('returns empty array when projectId is empty, null, or undefined', () => {
|
|
236
|
+
const md = createMockMarkdown()
|
|
237
|
+
expect(mod.chunkLearnedKnowledge(md, '')).toEqual([])
|
|
238
|
+
expect(mod.chunkLearnedKnowledge(md, null)).toEqual([])
|
|
239
|
+
expect(mod.chunkLearnedKnowledge(md, undefined)).toEqual([])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('falls back to all non-header content when "New knowledge:" section is empty', () => {
|
|
243
|
+
const md = [
|
|
244
|
+
'## Session: 2026-07-20 — Setup',
|
|
245
|
+
'',
|
|
246
|
+
'**Pipeline:** setup',
|
|
247
|
+
'',
|
|
248
|
+
'**New knowledge:**',
|
|
249
|
+
'',
|
|
250
|
+
'**What went wrong:**',
|
|
251
|
+
'- Nothing to learn here',
|
|
252
|
+
'',
|
|
253
|
+
'## Session: 2026-07-22 — Feature',
|
|
254
|
+
'',
|
|
255
|
+
'**Pipeline:** feature',
|
|
256
|
+
'',
|
|
257
|
+
'**New knowledge:**',
|
|
258
|
+
'- Actual learning content',
|
|
259
|
+
].join('\n')
|
|
260
|
+
|
|
261
|
+
const chunks = mod.chunkLearnedKnowledge(md, 'test-project')
|
|
262
|
+
// First session has empty "New knowledge:" → falls back to session content
|
|
263
|
+
expect(chunks).toHaveLength(2)
|
|
264
|
+
expect(chunks[0].session_date).toBe('2026-07-20')
|
|
265
|
+
expect(chunks[0].content).toContain('**Pipeline:** setup')
|
|
266
|
+
expect(chunks[0].content).toContain('Nothing to learn here')
|
|
267
|
+
// Second session has normal "New knowledge:" content
|
|
268
|
+
expect(chunks[1].session_date).toBe('2026-07-22')
|
|
269
|
+
expect(chunks[1].content).toBe('Actual learning content')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
274
|
+
// Graceful degradation — all functions return empty/zero results when
|
|
275
|
+
// DATABASE_URL is not configured. No exceptions are thrown.
|
|
276
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
277
|
+
|
|
278
|
+
describe('graceful degradation (without DATABASE_URL)', () => {
|
|
279
|
+
beforeEach(async () => {
|
|
280
|
+
vi.resetModules()
|
|
281
|
+
vi.clearAllMocks()
|
|
282
|
+
delete process.env.DATABASE_URL
|
|
283
|
+
mod = await import('./knowledgebase-index.js')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('getPool() returns null', async () => {
|
|
287
|
+
expect(await mod.getPool()).toBeNull()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('upsertChunks() returns {inserted:0, updated:0, skipped:0}', async () => {
|
|
291
|
+
const result = await mod.upsertChunks([
|
|
292
|
+
{ project_id: 'p', session_date: '2026-01-01', content: 'test' },
|
|
293
|
+
])
|
|
294
|
+
expect(result).toEqual({ inserted: 0, updated: 0, skipped: 0 })
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('search() returns []', async () => {
|
|
298
|
+
expect(await mod.search('test query', {})).toEqual([])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('getStats() returns zero-filled defaults', async () => {
|
|
302
|
+
expect(await mod.getStats()).toEqual({
|
|
303
|
+
total_chunks: 0,
|
|
304
|
+
total_projects: 0,
|
|
305
|
+
db_size: '0 MB',
|
|
306
|
+
last_sync: null,
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('listProjects() returns []', async () => {
|
|
311
|
+
expect(await mod.listProjects()).toEqual([])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('registerProject() returns id with null first_indexed_at', async () => {
|
|
315
|
+
expect(await mod.registerProject('my-project', 'My Project')).toEqual({
|
|
316
|
+
id: 'my-project',
|
|
317
|
+
first_indexed_at: null,
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('ensureSchema() does nothing — pool.query never called', async () => {
|
|
322
|
+
await expect(mod.ensureSchema()).resolves.toBeUndefined()
|
|
323
|
+
expect(mockPool.query).not.toHaveBeenCalled()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
328
|
+
// embed() — embedding generation via mocked @xenova/transformers pipeline
|
|
329
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
330
|
+
|
|
331
|
+
describe('embed', () => {
|
|
332
|
+
beforeEach(async () => {
|
|
333
|
+
vi.resetModules()
|
|
334
|
+
vi.clearAllMocks()
|
|
335
|
+
mod = await import('./knowledgebase-index.js')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('returns a Float32Array of length 384', async () => {
|
|
339
|
+
const vector = await mod.embed('test text to embed')
|
|
340
|
+
|
|
341
|
+
expect(vector).toBeInstanceOf(Float32Array)
|
|
342
|
+
expect(vector.length).toBe(384)
|
|
343
|
+
|
|
344
|
+
// 0.1 stored as Float32 → ~0.10000000149011612 due to precision loss
|
|
345
|
+
expect(vector[0]).toBeCloseTo(0.1, 2)
|
|
346
|
+
expect(vector[383]).toBeCloseTo(0.1, 2)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('calls the transformers pipeline with correct arguments', async () => {
|
|
350
|
+
await mod.embed('some query')
|
|
351
|
+
|
|
352
|
+
// pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
|
|
353
|
+
expect(mockPipeline).toHaveBeenCalledWith(
|
|
354
|
+
'feature-extraction',
|
|
355
|
+
'Xenova/all-MiniLM-L6-v2',
|
|
356
|
+
)
|
|
357
|
+
// model(text, { pooling: 'mean', normalize: true })
|
|
358
|
+
const mockModel = mockPipeline.mock.results[0].value
|
|
359
|
+
expect(mockModel).toHaveBeenCalledWith('some query', {
|
|
360
|
+
pooling: 'mean',
|
|
361
|
+
normalize: true,
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('throws when embedder model is not available', async () => {
|
|
366
|
+
// Save the original implementation, then override to return null.
|
|
367
|
+
// Must save BEFORE overriding since vi.clearAllMocks in beforeEach
|
|
368
|
+
// does NOT clear mock implementations.
|
|
369
|
+
const savedImpl = mockPipeline.getMockImplementation()
|
|
370
|
+
mockPipeline.mockImplementation(() => null)
|
|
371
|
+
|
|
372
|
+
vi.resetModules()
|
|
373
|
+
vi.clearAllMocks()
|
|
374
|
+
mod = await import('./knowledgebase-index.js')
|
|
375
|
+
|
|
376
|
+
await expect(mod.embed('test')).rejects.toThrow(
|
|
377
|
+
'Embedding model not available',
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
// Restore immediately so the "with database" tests are unaffected
|
|
381
|
+
mockPipeline.mockImplementation(savedImpl)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
386
|
+
// With database — DATABASE_URL is set; pool is available.
|
|
387
|
+
// mockPool.query() responses control what the core functions return.
|
|
388
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
389
|
+
|
|
390
|
+
describe('with database (DATABASE_URL set)', () => {
|
|
391
|
+
beforeEach(async () => {
|
|
392
|
+
vi.resetModules()
|
|
393
|
+
vi.clearAllMocks()
|
|
394
|
+
process.env.DATABASE_URL =
|
|
395
|
+
'postgres://user:pass@localhost:5432/knowledgebase'
|
|
396
|
+
mod = await import('./knowledgebase-index.js')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// ── getPool ────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
describe('getPool', () => {
|
|
402
|
+
it('returns a pool with validated config when DATABASE_URL is set', async () => {
|
|
403
|
+
const pool = await mod.getPool()
|
|
404
|
+
|
|
405
|
+
expect(pool).not.toBeNull()
|
|
406
|
+
expect(pool).toBe(mockPool)
|
|
407
|
+
|
|
408
|
+
// Pool constructor called with correct config
|
|
409
|
+
expect(Pool).toHaveBeenCalledWith({
|
|
410
|
+
connectionString:
|
|
411
|
+
'postgres://user:pass@localhost:5432/knowledgebase',
|
|
412
|
+
max: 5,
|
|
413
|
+
idleTimeoutMillis: 30000,
|
|
414
|
+
connectionTimeoutMillis: 5000,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// Error handler registered
|
|
418
|
+
expect(pool.on).toHaveBeenCalledWith('error', expect.any(Function))
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('returns the same pool instance on subsequent calls (singleton)', async () => {
|
|
422
|
+
const pool1 = await mod.getPool()
|
|
423
|
+
const pool2 = await mod.getPool()
|
|
424
|
+
|
|
425
|
+
expect(pool1).toBe(pool2)
|
|
426
|
+
expect(Pool).toHaveBeenCalledTimes(1)
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// ── registerProject ────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
describe('registerProject', () => {
|
|
433
|
+
it('calls pool.query with INSERT ... ON CONFLICT and returns project', async () => {
|
|
434
|
+
const expectedRow = {
|
|
435
|
+
id: 'my-project',
|
|
436
|
+
first_indexed_at: '2026-07-29T00:00:00.000Z',
|
|
437
|
+
}
|
|
438
|
+
mockPool.query.mockResolvedValue({ rows: [expectedRow] })
|
|
439
|
+
|
|
440
|
+
const result = await mod.registerProject('my-project', 'My Project')
|
|
441
|
+
|
|
442
|
+
expect(result).toEqual(expectedRow)
|
|
443
|
+
expect(mockPool.query).toHaveBeenCalledWith(
|
|
444
|
+
expect.stringContaining('INSERT INTO projects'),
|
|
445
|
+
['my-project', 'My Project'],
|
|
446
|
+
)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('returns id with null timestamp when pool.query fails', async () => {
|
|
450
|
+
mockPool.query.mockRejectedValue(new Error('DB error'))
|
|
451
|
+
|
|
452
|
+
const result = await mod.registerProject('broken-project', 'Broken')
|
|
453
|
+
|
|
454
|
+
expect(result).toEqual({ id: 'broken-project', first_indexed_at: null })
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// ── upsertChunks ──────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
describe('upsertChunks', () => {
|
|
461
|
+
const testChunks = [
|
|
462
|
+
{
|
|
463
|
+
project_id: 'test-project',
|
|
464
|
+
session_date: '2026-07-20',
|
|
465
|
+
pipeline: 'setup',
|
|
466
|
+
content: 'First knowledge item\nSecond knowledge item',
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
project_id: 'test-project',
|
|
470
|
+
session_date: '2026-07-22',
|
|
471
|
+
pipeline: 'feature',
|
|
472
|
+
content: 'Third knowledge item',
|
|
473
|
+
},
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
it('returns inserted count for new chunks (is_insert=true)', async () => {
|
|
477
|
+
mockPool.query
|
|
478
|
+
.mockResolvedValueOnce({ rows: [{ is_insert: true }] })
|
|
479
|
+
.mockResolvedValueOnce({ rows: [{ is_insert: true }] })
|
|
480
|
+
|
|
481
|
+
const result = await mod.upsertChunks(testChunks)
|
|
482
|
+
|
|
483
|
+
expect(result).toEqual({ inserted: 2, updated: 0, skipped: 0 })
|
|
484
|
+
expect(mockPool.query).toHaveBeenCalledTimes(2)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('counts duplicate content as updated (is_insert=false)', async () => {
|
|
488
|
+
mockPool.query
|
|
489
|
+
.mockResolvedValueOnce({ rows: [{ is_insert: false }] })
|
|
490
|
+
.mockResolvedValueOnce({ rows: [{ is_insert: true }] })
|
|
491
|
+
|
|
492
|
+
const result = await mod.upsertChunks(testChunks)
|
|
493
|
+
|
|
494
|
+
expect(result).toEqual({ inserted: 1, updated: 1, skipped: 0 })
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('counts unique violation (error code 23505) as skipped', async () => {
|
|
498
|
+
mockPool.query
|
|
499
|
+
.mockResolvedValueOnce({ rows: [{ is_insert: true }] })
|
|
500
|
+
.mockRejectedValueOnce({ code: '23505' })
|
|
501
|
+
|
|
502
|
+
const result = await mod.upsertChunks(testChunks)
|
|
503
|
+
|
|
504
|
+
expect(result).toEqual({ inserted: 1, updated: 0, skipped: 1 })
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('returns zero counts and does not call query for empty or null chunks', async () => {
|
|
508
|
+
expect(await mod.upsertChunks([])).toEqual({
|
|
509
|
+
inserted: 0,
|
|
510
|
+
updated: 0,
|
|
511
|
+
skipped: 0,
|
|
512
|
+
})
|
|
513
|
+
expect(await mod.upsertChunks(null)).toEqual({
|
|
514
|
+
inserted: 0,
|
|
515
|
+
updated: 0,
|
|
516
|
+
skipped: 0,
|
|
517
|
+
})
|
|
518
|
+
expect(mockPool.query).not.toHaveBeenCalled()
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
// ── search ─────────────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
describe('search', () => {
|
|
525
|
+
it('returns ranked results with similarity scores', async () => {
|
|
526
|
+
const fakeRows = [
|
|
527
|
+
makeChunkRow({
|
|
528
|
+
project_id: 'proj-a',
|
|
529
|
+
session_date: '2026-07-20',
|
|
530
|
+
pipeline: 'setup',
|
|
531
|
+
content: 'How to configure the pool',
|
|
532
|
+
similarity: 0.92,
|
|
533
|
+
}),
|
|
534
|
+
makeChunkRow({
|
|
535
|
+
project_id: 'proj-a',
|
|
536
|
+
session_date: '2026-07-22',
|
|
537
|
+
pipeline: 'feature',
|
|
538
|
+
content: 'How to mock transformers',
|
|
539
|
+
similarity: 0.78,
|
|
540
|
+
}),
|
|
541
|
+
]
|
|
542
|
+
mockPool.query.mockResolvedValue({ rows: fakeRows })
|
|
543
|
+
|
|
544
|
+
const results = await mod.search('pool configuration', {
|
|
545
|
+
project_id: 'proj-a',
|
|
546
|
+
limit: 5,
|
|
547
|
+
threshold: 0.6,
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
expect(results).toHaveLength(2)
|
|
551
|
+
expect(results[0].project_id).toBe('proj-a')
|
|
552
|
+
expect(results[0].similarity).toBe(0.92)
|
|
553
|
+
expect(results[0].pipeline).toBe('setup')
|
|
554
|
+
expect(results[0].session_date).toBe('2026-07-20')
|
|
555
|
+
expect(results[1].similarity).toBe(0.78)
|
|
556
|
+
|
|
557
|
+
// Verify SQL uses parameterized placeholders (never interpolates user input)
|
|
558
|
+
const sql = mockPool.query.mock.calls[0][0]
|
|
559
|
+
expect(sql).toContain('$1')
|
|
560
|
+
expect(sql).toContain('$2')
|
|
561
|
+
expect(sql).toContain('$3')
|
|
562
|
+
expect(sql).not.toContain('pool configuration')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('filters by project_id when provided', async () => {
|
|
566
|
+
mockPool.query.mockResolvedValue({ rows: [] })
|
|
567
|
+
|
|
568
|
+
await mod.search('test', { project_id: 'specific-project' })
|
|
569
|
+
|
|
570
|
+
const sql = mockPool.query.mock.calls[0][0]
|
|
571
|
+
expect(sql).toContain('AND project_id = $')
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('returns empty array when pool.query fails', async () => {
|
|
575
|
+
mockPool.query.mockRejectedValue(new Error('query failed'))
|
|
576
|
+
|
|
577
|
+
const results = await mod.search('test')
|
|
578
|
+
expect(results).toEqual([])
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// ── getStats ───────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
describe('getStats', () => {
|
|
585
|
+
it('returns aggregated statistics from database', async () => {
|
|
586
|
+
mockPool.query
|
|
587
|
+
.mockResolvedValueOnce({ rows: [{ count: 15 }] })
|
|
588
|
+
.mockResolvedValueOnce({ rows: [{ count: 3 }] })
|
|
589
|
+
.mockResolvedValueOnce({ rows: [{ size: '12 MB' }] })
|
|
590
|
+
.mockResolvedValueOnce({
|
|
591
|
+
rows: [{ last_sync: '2026-07-29T12:00:00.000Z' }],
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
const stats = await mod.getStats()
|
|
595
|
+
|
|
596
|
+
expect(stats).toEqual({
|
|
597
|
+
total_chunks: 15,
|
|
598
|
+
total_projects: 3,
|
|
599
|
+
db_size: '12 MB',
|
|
600
|
+
last_sync: '2026-07-29T12:00:00.000Z',
|
|
601
|
+
})
|
|
602
|
+
expect(mockPool.query).toHaveBeenCalledTimes(4)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('returns zero-filled defaults when pool.query fails', async () => {
|
|
606
|
+
mockPool.query.mockRejectedValue(new Error('stats error'))
|
|
607
|
+
|
|
608
|
+
expect(await mod.getStats()).toEqual({
|
|
609
|
+
total_chunks: 0,
|
|
610
|
+
total_projects: 0,
|
|
611
|
+
db_size: '0 MB',
|
|
612
|
+
last_sync: null,
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// ── listProjects ───────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
describe('listProjects', () => {
|
|
620
|
+
it('returns array of projects with chunk counts', async () => {
|
|
621
|
+
const fakeRows = [
|
|
622
|
+
{
|
|
623
|
+
project_id: 'proj-a',
|
|
624
|
+
name: 'Project A',
|
|
625
|
+
chunk_count: 10,
|
|
626
|
+
last_indexed: '2026-07-29T12:00:00.000Z',
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
project_id: 'proj-b',
|
|
630
|
+
name: 'Project B',
|
|
631
|
+
chunk_count: 5,
|
|
632
|
+
last_indexed: null,
|
|
633
|
+
},
|
|
634
|
+
]
|
|
635
|
+
mockPool.query.mockResolvedValue({ rows: fakeRows })
|
|
636
|
+
|
|
637
|
+
const projects = await mod.listProjects()
|
|
638
|
+
|
|
639
|
+
expect(projects).toHaveLength(2)
|
|
640
|
+
expect(projects[0]).toEqual({
|
|
641
|
+
project_id: 'proj-a',
|
|
642
|
+
name: 'Project A',
|
|
643
|
+
chunk_count: 10,
|
|
644
|
+
last_indexed: '2026-07-29T12:00:00.000Z',
|
|
645
|
+
})
|
|
646
|
+
expect(projects[1]).toEqual({
|
|
647
|
+
project_id: 'proj-b',
|
|
648
|
+
name: 'Project B',
|
|
649
|
+
chunk_count: 5,
|
|
650
|
+
last_indexed: null,
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('returns [] when pool.query fails', async () => {
|
|
655
|
+
mockPool.query.mockRejectedValue(new Error('list failed'))
|
|
656
|
+
expect(await mod.listProjects()).toEqual([])
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// ── ensureSchema ───────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
describe('ensureSchema', () => {
|
|
663
|
+
it('creates extension, tables, and indexes on success', async () => {
|
|
664
|
+
mockPool.query.mockResolvedValue({ rows: [] })
|
|
665
|
+
|
|
666
|
+
await mod.ensureSchema()
|
|
667
|
+
|
|
668
|
+
// First call is CREATE EXTENSION
|
|
669
|
+
expect(mockPool.query.mock.calls[0][0]).toContain(
|
|
670
|
+
'CREATE EXTENSION IF NOT EXISTS vector',
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
const allSql = mockPool.query.mock.calls.map((c) => c[0])
|
|
674
|
+
expect(
|
|
675
|
+
allSql.some((s) => s.includes('CREATE TABLE IF NOT EXISTS projects')),
|
|
676
|
+
).toBe(true)
|
|
677
|
+
expect(
|
|
678
|
+
allSql.some((s) =>
|
|
679
|
+
s.includes('CREATE TABLE IF NOT EXISTS knowledge_chunks'),
|
|
680
|
+
),
|
|
681
|
+
).toBe(true)
|
|
682
|
+
|
|
683
|
+
// 4 B-tree indexes + optional HNSW
|
|
684
|
+
const bTreeSql = allSql.filter(
|
|
685
|
+
(s) =>
|
|
686
|
+
s.includes('idx_knowledge_chunks_project_id') ||
|
|
687
|
+
s.includes('idx_knowledge_chunks_session_date') ||
|
|
688
|
+
s.includes('idx_knowledge_chunks_pipeline') ||
|
|
689
|
+
s.includes('idx_projects_last_indexed'),
|
|
690
|
+
)
|
|
691
|
+
expect(bTreeSql).toHaveLength(4)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('catches CREATE EXTENSION failure and continues with tables', async () => {
|
|
695
|
+
mockPool.query
|
|
696
|
+
.mockRejectedValueOnce(new Error('permission denied'))
|
|
697
|
+
.mockResolvedValue({ rows: [] })
|
|
698
|
+
|
|
699
|
+
await expect(mod.ensureSchema()).resolves.toBeUndefined()
|
|
700
|
+
|
|
701
|
+
// Table creation should still be attempted
|
|
702
|
+
expect(mockPool.query.mock.calls.length).toBeGreaterThan(1)
|
|
703
|
+
const secondSql = mockPool.query.mock.calls[1][0]
|
|
704
|
+
expect(secondSql).toContain('CREATE TABLE IF NOT EXISTS projects')
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it('catches HNSW index creation failure gracefully', async () => {
|
|
708
|
+
// Extension fails → tables created → HNSW fails → B-tree indexes succeed
|
|
709
|
+
mockPool.query
|
|
710
|
+
.mockRejectedValueOnce(new Error('extension not available'))
|
|
711
|
+
.mockResolvedValueOnce({ rows: [] }) // projects table
|
|
712
|
+
.mockResolvedValueOnce({ rows: [] }) // knowledge_chunks table
|
|
713
|
+
.mockRejectedValueOnce(new Error('HNSW not supported')) // HNSW index
|
|
714
|
+
.mockResolvedValue({ rows: [] }) // B-tree indexes
|
|
715
|
+
|
|
716
|
+
await expect(mod.ensureSchema()).resolves.toBeUndefined()
|
|
717
|
+
|
|
718
|
+
const allSql = mockPool.query.mock.calls.map((c) => c[0])
|
|
719
|
+
const bTreeSql = allSql.filter(
|
|
720
|
+
(s) =>
|
|
721
|
+
s.includes('idx_knowledge_chunks_project_id') ||
|
|
722
|
+
s.includes('idx_knowledge_chunks_session_date') ||
|
|
723
|
+
s.includes('idx_knowledge_chunks_pipeline') ||
|
|
724
|
+
s.includes('idx_projects_last_indexed'),
|
|
725
|
+
)
|
|
726
|
+
// All 4 B-tree indexes still created despite HNSW failure
|
|
727
|
+
expect(bTreeSql).toHaveLength(4)
|
|
728
|
+
})
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// ── closePool ──────────────────────────────────────────────────────────
|
|
732
|
+
|
|
733
|
+
describe('closePool', () => {
|
|
734
|
+
it('calls pool.end() and resets internal state for re-init', async () => {
|
|
735
|
+
await mod.getPool()
|
|
736
|
+
await mod.closePool()
|
|
737
|
+
|
|
738
|
+
expect(mockPool.end).toHaveBeenCalledTimes(1)
|
|
739
|
+
|
|
740
|
+
// After close, a fresh getPool() re-creates the pool
|
|
741
|
+
const poolAgain = await mod.getPool()
|
|
742
|
+
expect(poolAgain).toBe(mockPool)
|
|
743
|
+
expect(Pool).toHaveBeenCalledTimes(2)
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('is safe to call when pool was never created', async () => {
|
|
747
|
+
await expect(mod.closePool()).resolves.toBeUndefined()
|
|
748
|
+
expect(mockPool.end).not.toHaveBeenCalled()
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
// ── setEmbeddingProvider ────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
describe('setEmbeddingProvider', () => {
|
|
755
|
+
it('accepts a provider name without throwing', () => {
|
|
756
|
+
expect(() => mod.setEmbeddingProvider('openai')).not.toThrow()
|
|
757
|
+
})
|
|
758
|
+
})
|
|
759
|
+
})
|