bluera-knowledge 0.11.11 → 0.11.13

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluera-knowledge",
3
- "version": "0.11.11",
3
+ "version": "0.11.13",
4
4
  "description": "Clone repos, crawl docs, search locally. Fast, authoritative answers for AI coding agents.",
5
5
  "mcpServers": {
6
6
  "bluera-knowledge": {
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [0.11.13](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.13) (2026-01-10)
6
+
7
+
8
+ ### Features
9
+
10
+ * **scripts:** add post-release npm validation script ([e4c29a0](https://github.com/blueraai/bluera-knowledge/commit/e4c29a0c83907de4bc293a69a58412629457fb22))
11
+ * **scripts:** add suggest, sync, serve, mcp tests to npm validation ([49d85da](https://github.com/blueraai/bluera-knowledge/commit/49d85dad1a89691060c12f152d644844baf6e6e6))
12
+ * **scripts:** log expected vs installed version in validation script ([c77d039](https://github.com/blueraai/bluera-knowledge/commit/c77d039b27a3ccf54d50006af161ac4dcfea7b21))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **cli:** plugin-api commands now respect global options ([d3cca02](https://github.com/blueraai/bluera-knowledge/commit/d3cca02ffc679ffc187b76c7682f3cc177eabdea))
18
+ * **plugin:** properly close services after command execution ([eeaf743](https://github.com/blueraai/bluera-knowledge/commit/eeaf743750be73fd9c7a9e72440b2fd0fb5a53fa))
19
+ * **scripts:** show real-time output in validation script ([8a4bdec](https://github.com/blueraai/bluera-knowledge/commit/8a4bdec8b63c504d34ba35bfe19da795f7f7fd07))
20
+ * **scripts:** use mktemp for temp directories in validation script ([3107861](https://github.com/blueraai/bluera-knowledge/commit/3107861bd7a966016fde2a121469dd84756f39be))
21
+ * **search:** add defaults for env vars so CLI works standalone ([b2d2ce5](https://github.com/blueraai/bluera-knowledge/commit/b2d2ce534e8cd2ba0fc0abdac505c912a1a76035))
22
+
23
+ ## [0.11.12](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.12) (2026-01-10)
24
+
25
+
26
+ ### Features
27
+
28
+ * **scripts:** add post-release npm validation script ([e4c29a0](https://github.com/blueraai/bluera-knowledge/commit/e4c29a0c83907de4bc293a69a58412629457fb22))
29
+ * **scripts:** add suggest, sync, serve, mcp tests to npm validation ([49d85da](https://github.com/blueraai/bluera-knowledge/commit/49d85dad1a89691060c12f152d644844baf6e6e6))
30
+ * **scripts:** log expected vs installed version in validation script ([c77d039](https://github.com/blueraai/bluera-knowledge/commit/c77d039b27a3ccf54d50006af161ac4dcfea7b21))
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * **cli:** plugin-api commands now respect global options ([d3cca02](https://github.com/blueraai/bluera-knowledge/commit/d3cca02ffc679ffc187b76c7682f3cc177eabdea))
36
+ * **plugin:** properly close services after command execution ([eeaf743](https://github.com/blueraai/bluera-knowledge/commit/eeaf743750be73fd9c7a9e72440b2fd0fb5a53fa))
37
+ * **scripts:** show real-time output in validation script ([8a4bdec](https://github.com/blueraai/bluera-knowledge/commit/8a4bdec8b63c504d34ba35bfe19da795f7f7fd07))
38
+ * **scripts:** use mktemp for temp directories in validation script ([3107861](https://github.com/blueraai/bluera-knowledge/commit/3107861bd7a966016fde2a121469dd84756f39be))
39
+ * **search:** add defaults for env vars so CLI works standalone ([b2d2ce5](https://github.com/blueraai/bluera-knowledge/commit/b2d2ce534e8cd2ba0fc0abdac505c912a1a76035))
40
+
5
41
  ## [0.11.11](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.11) (2026-01-10)
6
42
 
7
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluera-knowledge",
3
- "version": "0.11.11",
3
+ "version": "0.11.13",
4
4
  "description": "CLI tool for managing knowledge stores with semantic search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -213,6 +213,90 @@ else
213
213
  pass "Store successfully deleted"
214
214
  fi
215
215
 
216
+ # Test suggest command
217
+ log_header "Testing suggest"
218
+
219
+ # Create a minimal package.json for suggest to find
220
+ echo '{"dependencies": {"lodash": "^4.0.0"}}' > "$TEST_FOLDER/package.json"
221
+ run_test "bluera-knowledge suggest" "bluera-knowledge suggest -p '$TEST_FOLDER' -d '$DATA_DIR'"
222
+
223
+ # Test sync command (should work with no definitions - returns empty result)
224
+ log_header "Testing sync"
225
+
226
+ run_test_contains "bluera-knowledge sync (no definitions)" "Sync completed" "bluera-knowledge sync -p '$TEST_FOLDER' -d '$DATA_DIR'"
227
+
228
+ # Test sync with a definitions file
229
+ mkdir -p "$TEST_FOLDER/.bluera/bluera-knowledge"
230
+ cat > "$TEST_FOLDER/.bluera/bluera-knowledge/stores.config.json" << EOF
231
+ {
232
+ "version": 1,
233
+ "stores": [
234
+ {
235
+ "name": "sync-test-store",
236
+ "type": "file",
237
+ "path": "."
238
+ }
239
+ ]
240
+ }
241
+ EOF
242
+ run_test "bluera-knowledge sync (with definitions)" "bluera-knowledge sync -p '$TEST_FOLDER' -d '$DATA_DIR'"
243
+
244
+ # Verify sync created the store
245
+ run_test_contains "Sync created store" "sync-test-store" "bluera-knowledge stores -d '$DATA_DIR'"
246
+
247
+ # Clean up sync test store
248
+ bluera-knowledge store delete "sync-test-store" --force -d "$DATA_DIR" 2>/dev/null || true
249
+
250
+ # Test serve command (start, test, stop)
251
+ log_header "Testing serve"
252
+
253
+ SERVE_PORT=19876
254
+ SERVE_PID=""
255
+
256
+ # Start server in background
257
+ log "Starting serve on port $SERVE_PORT..."
258
+ bluera-knowledge serve --port $SERVE_PORT -d "$DATA_DIR" &
259
+ SERVE_PID=$!
260
+
261
+ # Give it time to start
262
+ sleep 2
263
+
264
+ # Check if server is running
265
+ if kill -0 $SERVE_PID 2>/dev/null; then
266
+ log "Server started with PID $SERVE_PID"
267
+
268
+ # Test health endpoint
269
+ if curl -s "http://localhost:$SERVE_PORT/health" | grep -q "ok"; then
270
+ pass "bluera-knowledge serve (health endpoint)"
271
+ else
272
+ fail "bluera-knowledge serve (health endpoint not responding)"
273
+ fi
274
+
275
+ # Stop server
276
+ kill $SERVE_PID 2>/dev/null || true
277
+ wait $SERVE_PID 2>/dev/null || true
278
+ log "Server stopped"
279
+ else
280
+ fail "bluera-knowledge serve (failed to start)"
281
+ fi
282
+
283
+ # Test mcp command (just verify it starts and outputs JSON-RPC)
284
+ log_header "Testing mcp"
285
+
286
+ MCP_OUTPUT=$(timeout 2 bluera-knowledge mcp -d "$DATA_DIR" 2>&1 || true)
287
+ if echo "$MCP_OUTPUT" | grep -qE "(jsonrpc|ready|listening|MCP)" 2>/dev/null || [ $? -eq 124 ]; then
288
+ # Timeout (124) is expected - MCP keeps running until killed
289
+ pass "bluera-knowledge mcp (starts without error)"
290
+ else
291
+ # Even if it times out or produces no output, as long as it didn't crash
292
+ if [ -z "$MCP_OUTPUT" ]; then
293
+ pass "bluera-knowledge mcp (starts without error)"
294
+ else
295
+ log "MCP output: $MCP_OUTPUT"
296
+ fail "bluera-knowledge mcp (unexpected output)"
297
+ fi
298
+ fi
299
+
216
300
  # Summary
217
301
  log_header "Validation Summary"
218
302
  log "Tests run: $TESTS_RUN"
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
2
+ import { spawn } from 'node:child_process';
3
+ import { rm, mkdtemp, writeFile, mkdir } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import * as readline from 'node:readline';
7
+
8
+ /**
9
+ * MCP Server Integration Tests
10
+ *
11
+ * Tests that the MCP server actually starts and accepts JSON-RPC messages.
12
+ * Unlike unit tests that mock runMCPServer, these tests spawn a real
13
+ * MCP server process and communicate via stdin/stdout.
14
+ */
15
+ describe('MCP Integration', () => {
16
+ let tempDir: string;
17
+ let testFilesDir: string;
18
+
19
+ beforeAll(async () => {
20
+ tempDir = await mkdtemp(join(tmpdir(), 'mcp-test-'));
21
+ testFilesDir = join(tempDir, 'files');
22
+ await mkdir(testFilesDir, { recursive: true });
23
+ await writeFile(
24
+ join(testFilesDir, 'test.md'),
25
+ '# Test Document\n\nContent for MCP integration testing.'
26
+ );
27
+ }, 30000);
28
+
29
+ afterAll(async () => {
30
+ await rm(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ interface JSONRPCResponse {
34
+ jsonrpc: string;
35
+ id: number;
36
+ result?: unknown;
37
+ error?: { code: number; message: string };
38
+ }
39
+
40
+ interface MCPClient {
41
+ proc: ChildProcess;
42
+ sendRequest: (method: string, params?: Record<string, unknown>) => Promise<JSONRPCResponse>;
43
+ close: () => void;
44
+ }
45
+
46
+ /**
47
+ * Start the MCP server and return a client with message helpers
48
+ */
49
+ function startMCPServer(): MCPClient {
50
+ const proc = spawn('node', ['dist/mcp/server.js'], {
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ env: {
53
+ ...process.env,
54
+ PROJECT_ROOT: tempDir,
55
+ DATA_DIR: join(tempDir, 'data'),
56
+ CONFIG_PATH: join(tempDir, 'config.json'),
57
+ },
58
+ });
59
+
60
+ // Set up a single readline interface for the entire process lifetime
61
+ const rl = readline.createInterface({ input: proc.stdout! });
62
+ const pendingRequests = new Map<
63
+ number,
64
+ { resolve: (r: JSONRPCResponse) => void; reject: (e: Error) => void }
65
+ >();
66
+ let requestId = 0;
67
+
68
+ rl.on('line', (line: string) => {
69
+ try {
70
+ const response = JSON.parse(line) as JSONRPCResponse;
71
+ const pending = pendingRequests.get(response.id);
72
+ if (pending !== undefined) {
73
+ pendingRequests.delete(response.id);
74
+ pending.resolve(response);
75
+ }
76
+ } catch {
77
+ // Ignore non-JSON lines (like log output)
78
+ }
79
+ });
80
+
81
+ return {
82
+ proc,
83
+ sendRequest: (
84
+ method: string,
85
+ params: Record<string, unknown> = {}
86
+ ): Promise<JSONRPCResponse> => {
87
+ return new Promise((resolve, reject) => {
88
+ if (proc.stdin === null) {
89
+ reject(new Error('Process stdin not available'));
90
+ return;
91
+ }
92
+
93
+ const id = ++requestId;
94
+ const timeout = setTimeout(() => {
95
+ pendingRequests.delete(id);
96
+ reject(new Error(`Timeout waiting for response to ${method}`));
97
+ }, 30000);
98
+
99
+ pendingRequests.set(id, {
100
+ resolve: (r) => {
101
+ clearTimeout(timeout);
102
+ resolve(r);
103
+ },
104
+ reject: (e) => {
105
+ clearTimeout(timeout);
106
+ reject(e);
107
+ },
108
+ });
109
+
110
+ const request = JSON.stringify({
111
+ jsonrpc: '2.0',
112
+ id,
113
+ method,
114
+ params,
115
+ });
116
+
117
+ proc.stdin.write(request + '\n');
118
+ });
119
+ },
120
+ close: (): void => {
121
+ rl.close();
122
+ proc.kill('SIGTERM');
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Wait for process to be ready
129
+ */
130
+ async function waitForReady(client: MCPClient, timeoutMs = 10000): Promise<void> {
131
+ return new Promise((resolve, reject) => {
132
+ const timeout = setTimeout(() => {
133
+ reject(new Error('Timeout waiting for MCP server to start'));
134
+ }, timeoutMs);
135
+
136
+ // MCP servers typically become ready quickly
137
+ // Give it a moment to initialize
138
+ setTimeout(() => {
139
+ clearTimeout(timeout);
140
+ resolve();
141
+ }, 1000);
142
+
143
+ client.proc.on('error', (err) => {
144
+ clearTimeout(timeout);
145
+ reject(err);
146
+ });
147
+
148
+ client.proc.on('exit', (code) => {
149
+ clearTimeout(timeout);
150
+ if (code !== 0 && code !== null) {
151
+ reject(new Error(`MCP server exited with code ${code}`));
152
+ }
153
+ });
154
+ });
155
+ }
156
+
157
+ let mcpClient: MCPClient | null = null;
158
+
159
+ afterEach(async () => {
160
+ // Clean up MCP client if still running
161
+ if (mcpClient !== null) {
162
+ mcpClient.close();
163
+ await new Promise<void>((resolve) => {
164
+ if (mcpClient !== null) {
165
+ mcpClient.proc.on('exit', () => resolve());
166
+ setTimeout(() => {
167
+ if (mcpClient !== null) {
168
+ mcpClient.proc.kill('SIGKILL');
169
+ }
170
+ resolve();
171
+ }, 2000);
172
+ } else {
173
+ resolve();
174
+ }
175
+ });
176
+ mcpClient = null;
177
+ }
178
+ });
179
+
180
+ it('starts, lists tools, and handles tool execution', async () => {
181
+ mcpClient = startMCPServer();
182
+ await waitForReady(mcpClient);
183
+
184
+ // 1. Initialize
185
+ const initResponse = await mcpClient.sendRequest('initialize', {
186
+ protocolVersion: '2024-11-05',
187
+ capabilities: {},
188
+ clientInfo: { name: 'test-client', version: '1.0.0' },
189
+ });
190
+
191
+ expect(initResponse.jsonrpc).toBe('2.0');
192
+ expect(initResponse.error).toBeUndefined();
193
+ expect(initResponse.result).toBeDefined();
194
+
195
+ const initResult = initResponse.result as {
196
+ protocolVersion: string;
197
+ serverInfo: { name: string; version: string };
198
+ capabilities: { tools: Record<string, unknown> };
199
+ };
200
+ expect(initResult.serverInfo.name).toBe('bluera-knowledge');
201
+ expect(initResult.capabilities.tools).toBeDefined();
202
+
203
+ // 2. List tools
204
+ const toolsResponse = await mcpClient.sendRequest('tools/list', {});
205
+
206
+ expect(toolsResponse.error).toBeUndefined();
207
+ expect(toolsResponse.result).toBeDefined();
208
+
209
+ const toolsResult = toolsResponse.result as {
210
+ tools: Array<{ name: string; description: string }>;
211
+ };
212
+ expect(Array.isArray(toolsResult.tools)).toBe(true);
213
+
214
+ const toolNames = toolsResult.tools.map((t) => t.name);
215
+ expect(toolNames).toContain('search');
216
+ expect(toolNames).toContain('get_full_context');
217
+ expect(toolNames).toContain('execute');
218
+
219
+ // 3. Call search tool
220
+ const searchResponse = await mcpClient.sendRequest('tools/call', {
221
+ name: 'search',
222
+ arguments: {
223
+ query: 'test query',
224
+ limit: 5,
225
+ },
226
+ });
227
+
228
+ expect(searchResponse.error).toBeUndefined();
229
+ expect(searchResponse.result).toBeDefined();
230
+
231
+ const searchResult = searchResponse.result as {
232
+ content: Array<{ type: string; text: string }>;
233
+ };
234
+ expect(Array.isArray(searchResult.content)).toBe(true);
235
+
236
+ // 4. Call execute with help command
237
+ const helpResponse = await mcpClient.sendRequest('tools/call', {
238
+ name: 'execute',
239
+ arguments: {
240
+ command: 'help',
241
+ },
242
+ });
243
+
244
+ expect(helpResponse.error).toBeUndefined();
245
+ expect(helpResponse.result).toBeDefined();
246
+
247
+ const helpResult = helpResponse.result as { content: Array<{ type: string; text: string }> };
248
+ expect(helpResult.content[0]?.text).toContain('Available commands');
249
+ }, 120000);
250
+ });
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { rm, mkdtemp, writeFile, mkdir } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ /**
8
+ * Serve Command Integration Tests
9
+ *
10
+ * Tests that the HTTP server actually starts, responds to requests,
11
+ * and shuts down cleanly. Unlike unit tests that mock the serve function,
12
+ * these tests spawn a real server process and make actual HTTP requests.
13
+ */
14
+ describe('Serve Integration', () => {
15
+ let tempDir: string;
16
+ let testFilesDir: string;
17
+ let serverProcess: ChildProcess | null = null;
18
+ const TEST_PORT = 19877; // Use a unique port to avoid conflicts
19
+
20
+ beforeAll(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), 'serve-test-'));
22
+ testFilesDir = join(tempDir, 'files');
23
+ await mkdir(testFilesDir, { recursive: true });
24
+ await writeFile(
25
+ join(testFilesDir, 'test.md'),
26
+ '# Test Document\n\nContent for serve integration testing.'
27
+ );
28
+ }, 30000);
29
+
30
+ afterAll(async () => {
31
+ await rm(tempDir, { recursive: true, force: true });
32
+ });
33
+
34
+ afterEach(async () => {
35
+ // Clean up server process if still running
36
+ if (serverProcess !== null) {
37
+ serverProcess.kill('SIGTERM');
38
+ await new Promise<void>((resolve) => {
39
+ if (serverProcess !== null) {
40
+ serverProcess.on('exit', () => resolve());
41
+ // Force kill after timeout
42
+ setTimeout(() => {
43
+ if (serverProcess !== null) {
44
+ serverProcess.kill('SIGKILL');
45
+ }
46
+ resolve();
47
+ }, 2000);
48
+ } else {
49
+ resolve();
50
+ }
51
+ });
52
+ serverProcess = null;
53
+ }
54
+ });
55
+
56
+ /**
57
+ * Start the serve command and wait for it to be ready
58
+ */
59
+ async function startServer(port: number): Promise<ChildProcess> {
60
+ return new Promise((resolve, reject) => {
61
+ const proc = spawn(
62
+ 'node',
63
+ ['dist/index.js', 'serve', '--port', String(port), '--data-dir', tempDir],
64
+ {
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ }
67
+ );
68
+
69
+ let resolved = false;
70
+ const timeout = setTimeout(() => {
71
+ if (!resolved) {
72
+ reject(new Error('Server startup timeout'));
73
+ proc.kill();
74
+ }
75
+ }, 30000);
76
+
77
+ proc.stdout?.on('data', (data: Buffer) => {
78
+ const output = data.toString();
79
+ if (output.includes('Starting server')) {
80
+ resolved = true;
81
+ clearTimeout(timeout);
82
+ // Give it a moment to actually start listening
83
+ setTimeout(() => resolve(proc), 500);
84
+ }
85
+ });
86
+
87
+ proc.stderr?.on('data', (data: Buffer) => {
88
+ // Log stderr but don't fail - some warnings are expected
89
+ console.error('Server stderr:', data.toString());
90
+ });
91
+
92
+ proc.on('error', (err) => {
93
+ if (!resolved) {
94
+ clearTimeout(timeout);
95
+ reject(err);
96
+ }
97
+ });
98
+
99
+ proc.on('exit', (code) => {
100
+ if (!resolved) {
101
+ clearTimeout(timeout);
102
+ reject(new Error(`Server exited with code ${String(code)}`));
103
+ }
104
+ });
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Make an HTTP request with timeout
110
+ */
111
+ async function fetchWithTimeout(
112
+ url: string,
113
+ options: RequestInit = {},
114
+ timeoutMs = 5000
115
+ ): Promise<Response> {
116
+ const controller = new AbortController();
117
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
118
+ try {
119
+ return await fetch(url, { ...options, signal: controller.signal });
120
+ } finally {
121
+ clearTimeout(timeoutId);
122
+ }
123
+ }
124
+
125
+ describe('Server Startup and Health', () => {
126
+ it('starts server and responds to /health', async () => {
127
+ serverProcess = await startServer(TEST_PORT);
128
+
129
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT}/health`);
130
+ expect(response.ok).toBe(true);
131
+
132
+ const body = (await response.json()) as { status: string };
133
+ expect(body.status).toBe('ok');
134
+ }, 60000);
135
+
136
+ it('responds with CORS headers', async () => {
137
+ serverProcess = await startServer(TEST_PORT + 1);
138
+
139
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 1}/health`, {
140
+ method: 'OPTIONS',
141
+ headers: {
142
+ Origin: 'http://localhost:3000',
143
+ 'Access-Control-Request-Method': 'GET',
144
+ },
145
+ });
146
+
147
+ // CORS preflight should succeed
148
+ expect(response.ok).toBe(true);
149
+ }, 60000);
150
+ });
151
+
152
+ describe('Store API', () => {
153
+ beforeEach(async () => {
154
+ serverProcess = await startServer(TEST_PORT + 2);
155
+ });
156
+
157
+ it('lists stores via GET /api/stores', async () => {
158
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`);
159
+ expect(response.ok).toBe(true);
160
+
161
+ const body = (await response.json()) as unknown[];
162
+ expect(Array.isArray(body)).toBe(true);
163
+ }, 60000);
164
+
165
+ it('creates store via POST /api/stores', async () => {
166
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`, {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({
170
+ name: 'serve-test-store',
171
+ type: 'file',
172
+ path: testFilesDir,
173
+ }),
174
+ });
175
+
176
+ expect(response.status).toBe(201);
177
+ const body = (await response.json()) as { id: string; name: string };
178
+ expect(body.name).toBe('serve-test-store');
179
+ expect(body.id).toBeDefined();
180
+ }, 60000);
181
+
182
+ it('returns 400 for invalid store creation', async () => {
183
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`, {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({
187
+ name: 'invalid-store',
188
+ type: 'file',
189
+ // Missing required 'path' for file type
190
+ }),
191
+ });
192
+
193
+ expect(response.status).toBe(400);
194
+ }, 60000);
195
+ });
196
+
197
+ describe('Search API', () => {
198
+ it('handles search request via POST /api/search', async () => {
199
+ serverProcess = await startServer(TEST_PORT + 3);
200
+
201
+ // Search with no stores indexed - should return empty results
202
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 3}/api/search`, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({
206
+ query: 'test query',
207
+ limit: 5,
208
+ }),
209
+ });
210
+
211
+ expect(response.ok).toBe(true);
212
+ const body = (await response.json()) as { results: unknown[] };
213
+ expect(body.results).toBeDefined();
214
+ expect(Array.isArray(body.results)).toBe(true);
215
+ }, 60000);
216
+
217
+ it('returns 400 for invalid search request', async () => {
218
+ serverProcess = await startServer(TEST_PORT + 4);
219
+
220
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 4}/api/search`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify({
224
+ // Missing required 'query'
225
+ }),
226
+ });
227
+
228
+ expect(response.status).toBe(400);
229
+ }, 60000);
230
+ });
231
+
232
+ describe('Graceful Shutdown', () => {
233
+ it('shuts down cleanly on SIGTERM', async () => {
234
+ serverProcess = await startServer(TEST_PORT + 5);
235
+
236
+ // Verify server is running
237
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 5}/health`);
238
+ expect(response.ok).toBe(true);
239
+
240
+ // Send SIGTERM
241
+ serverProcess.kill('SIGTERM');
242
+
243
+ // Wait for process to exit
244
+ const exitCode = await new Promise<number | null>((resolve) => {
245
+ if (serverProcess !== null) {
246
+ serverProcess.on('exit', (code) => resolve(code));
247
+ // Allow more time for graceful shutdown
248
+ setTimeout(() => resolve(null), 10000);
249
+ } else {
250
+ resolve(null);
251
+ }
252
+ });
253
+
254
+ // Process should exit cleanly (code 0) or be killed (null)
255
+ // On some systems the process may not exit with 0 due to async cleanup
256
+ expect(exitCode === 0 || exitCode === null).toBe(true);
257
+ serverProcess = null; // Already exited
258
+ }, 60000);
259
+ });
260
+ });