@abarcenas/ai-workflow-template 1.34.0 → 1.39.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.
Files changed (34) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +238 -0
  2. package/.agents/instructions/learned-knowledge.instructions.md +91 -0
  3. package/.husky/post-commit +10 -0
  4. package/.husky/post-merge +0 -1
  5. package/.opencode/agents/architect.agent.md +1 -0
  6. package/.opencode/agents/coder.agent.md +1 -0
  7. package/.opencode/agents/deployer.agent.md +1 -0
  8. package/.opencode/agents/designer.agent.md +1 -0
  9. package/.opencode/agents/e2e-tester.agent.md +1 -0
  10. package/.opencode/agents/implementer.agent.md +1 -0
  11. package/.opencode/agents/orchestrator/feature-pipeline.agent.md +1 -0
  12. package/.opencode/agents/orchestrator/orchestrator.agent.md +1 -0
  13. package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +1 -0
  14. package/.opencode/agents/researcher.agent.md +1 -0
  15. package/.opencode/agents/reviewer.agent.md +1 -0
  16. package/.opencode/agents/tracker.agent.md +2 -0
  17. package/.opencode/agents/unit-tester.agent.md +1 -0
  18. package/README.md +1 -1
  19. package/memory-bank/.vocabulary.json +8 -2
  20. package/package.json +12 -6
  21. package/scripts/knowledgebase-cli.js +228 -0
  22. package/scripts/knowledgebase-cli.test.js +576 -0
  23. package/scripts/knowledgebase-index.js +786 -0
  24. package/scripts/knowledgebase-index.test.js +759 -0
  25. package/scripts/knowledgebase-init.sql +193 -0
  26. package/scripts/mcp-knowledgebase-server.js +330 -0
  27. package/scripts/setup/constants.js +36 -3
  28. package/scripts/setup/hooks.js +2 -2
  29. package/scripts/setup/hooks.test.js +13 -5
  30. package/scripts/setup/index.js +29 -2
  31. package/scripts/setup/index.test.js +36 -1
  32. package/scripts/setup/knowledgebase.js +204 -0
  33. package/scripts/setup/knowledgebase.test.js +248 -0
  34. 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
+ })