@qwickapps/qwickbrain-proxy 1.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/.github/workflows/publish.yml +92 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +45 -0
- package/README.md +165 -0
- package/dist/bin/cli.d.ts +3 -0
- package/dist/bin/cli.d.ts.map +1 -0
- package/dist/bin/cli.js +142 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/db/client.d.ts +10 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +23 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/schema.d.ts +551 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +65 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/__tests__/cache-manager.test.d.ts +2 -0
- package/dist/lib/__tests__/cache-manager.test.d.ts.map +1 -0
- package/dist/lib/__tests__/cache-manager.test.js +202 -0
- package/dist/lib/__tests__/cache-manager.test.js.map +1 -0
- package/dist/lib/__tests__/connection-manager.test.d.ts +2 -0
- package/dist/lib/__tests__/connection-manager.test.d.ts.map +1 -0
- package/dist/lib/__tests__/connection-manager.test.js +188 -0
- package/dist/lib/__tests__/connection-manager.test.js.map +1 -0
- package/dist/lib/__tests__/proxy-server.test.d.ts +2 -0
- package/dist/lib/__tests__/proxy-server.test.d.ts.map +1 -0
- package/dist/lib/__tests__/proxy-server.test.js +205 -0
- package/dist/lib/__tests__/proxy-server.test.js.map +1 -0
- package/dist/lib/__tests__/qwickbrain-client.test.d.ts +2 -0
- package/dist/lib/__tests__/qwickbrain-client.test.d.ts.map +1 -0
- package/dist/lib/__tests__/qwickbrain-client.test.js +233 -0
- package/dist/lib/__tests__/qwickbrain-client.test.js.map +1 -0
- package/dist/lib/cache-manager.d.ts +25 -0
- package/dist/lib/cache-manager.d.ts.map +1 -0
- package/dist/lib/cache-manager.js +149 -0
- package/dist/lib/cache-manager.js.map +1 -0
- package/dist/lib/connection-manager.d.ts +26 -0
- package/dist/lib/connection-manager.d.ts.map +1 -0
- package/dist/lib/connection-manager.js +130 -0
- package/dist/lib/connection-manager.js.map +1 -0
- package/dist/lib/proxy-server.d.ts +19 -0
- package/dist/lib/proxy-server.d.ts.map +1 -0
- package/dist/lib/proxy-server.js +258 -0
- package/dist/lib/proxy-server.js.map +1 -0
- package/dist/lib/qwickbrain-client.d.ts +24 -0
- package/dist/lib/qwickbrain-client.d.ts.map +1 -0
- package/dist/lib/qwickbrain-client.js +197 -0
- package/dist/lib/qwickbrain-client.js.map +1 -0
- package/dist/types/config.d.ts +186 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +42 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/mcp.d.ts +223 -0
- package/dist/types/mcp.d.ts.map +1 -0
- package/dist/types/mcp.js +78 -0
- package/dist/types/mcp.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/drizzle/0000_fat_rafael_vega.sql +41 -0
- package/drizzle/0001_goofy_invisible_woman.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +276 -0
- package/drizzle/meta/0001_snapshot.json +295 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +12 -0
- package/package.json +65 -0
- package/src/bin/cli.ts +158 -0
- package/src/db/client.ts +34 -0
- package/src/db/schema.ts +68 -0
- package/src/index.ts +6 -0
- package/src/lib/__tests__/cache-manager.test.ts +264 -0
- package/src/lib/__tests__/connection-manager.test.ts +255 -0
- package/src/lib/__tests__/proxy-server.test.ts +261 -0
- package/src/lib/__tests__/qwickbrain-client.test.ts +310 -0
- package/src/lib/cache-manager.ts +201 -0
- package/src/lib/connection-manager.ts +156 -0
- package/src/lib/proxy-server.ts +320 -0
- package/src/lib/qwickbrain-client.ts +260 -0
- package/src/types/config.ts +47 -0
- package/src/types/mcp.ts +97 -0
- package/src/version.ts +11 -0
- package/test/fixtures/test-mcp.json +5 -0
- package/test-mcp-client.js +67 -0
- package/test-proxy.sh +25 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { ConnectionManager } from '../connection-manager.js';
|
|
3
|
+
import type { QwickBrainClient } from '../qwickbrain-client.js';
|
|
4
|
+
import type { Config } from '../../types/config.js';
|
|
5
|
+
|
|
6
|
+
describe('ConnectionManager', () => {
|
|
7
|
+
let mockClient: QwickBrainClient;
|
|
8
|
+
let connectionManager: ConnectionManager;
|
|
9
|
+
let config: Config['connection'];
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
config = {
|
|
13
|
+
healthCheckInterval: 100, // Short interval for testing
|
|
14
|
+
timeout: 5000,
|
|
15
|
+
maxReconnectAttempts: 3,
|
|
16
|
+
reconnectBackoff: {
|
|
17
|
+
initial: 50,
|
|
18
|
+
multiplier: 2,
|
|
19
|
+
max: 500,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
mockClient = {
|
|
24
|
+
healthCheck: vi.fn().mockResolvedValue(true),
|
|
25
|
+
} as unknown as QwickBrainClient;
|
|
26
|
+
|
|
27
|
+
connectionManager = new ConnectionManager(mockClient, config);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
connectionManager.stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('getState', () => {
|
|
35
|
+
it('should return initial state as disconnected', () => {
|
|
36
|
+
expect(connectionManager.getState()).toBe('disconnected');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('start and healthCheck', () => {
|
|
41
|
+
it('should transition to connected on successful health check', async () => {
|
|
42
|
+
await connectionManager.start();
|
|
43
|
+
|
|
44
|
+
expect(connectionManager.getState()).toBe('connected');
|
|
45
|
+
expect(mockClient.healthCheck).toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should transition to reconnecting after failed health check', async () => {
|
|
49
|
+
mockClient.healthCheck = vi.fn().mockRejectedValue(new Error('Connection failed'));
|
|
50
|
+
|
|
51
|
+
await connectionManager.start();
|
|
52
|
+
|
|
53
|
+
// After health check fails, it immediately schedules reconnect
|
|
54
|
+
expect(connectionManager.getState()).toBe('reconnecting');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should emit stateChange event on state transition', async () => {
|
|
58
|
+
const stateChangeListener = vi.fn();
|
|
59
|
+
connectionManager.on('stateChange', stateChangeListener);
|
|
60
|
+
|
|
61
|
+
await connectionManager.start();
|
|
62
|
+
|
|
63
|
+
expect(stateChangeListener).toHaveBeenCalledWith({
|
|
64
|
+
from: 'disconnected',
|
|
65
|
+
to: 'connected',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should emit connected event with latency', async () => {
|
|
70
|
+
const connectedListener = vi.fn();
|
|
71
|
+
connectionManager.on('connected', connectedListener);
|
|
72
|
+
|
|
73
|
+
await connectionManager.start();
|
|
74
|
+
|
|
75
|
+
expect(connectedListener).toHaveBeenCalled();
|
|
76
|
+
const call = connectedListener.mock.calls[0][0];
|
|
77
|
+
expect(call).toHaveProperty('latencyMs');
|
|
78
|
+
expect(typeof call.latencyMs).toBe('number');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should emit disconnected event on failure', async () => {
|
|
82
|
+
mockClient.healthCheck = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
83
|
+
const disconnectedListener = vi.fn();
|
|
84
|
+
connectionManager.on('disconnected', disconnectedListener);
|
|
85
|
+
|
|
86
|
+
await connectionManager.start();
|
|
87
|
+
|
|
88
|
+
expect(disconnectedListener).toHaveBeenCalled();
|
|
89
|
+
expect(disconnectedListener.mock.calls[0][0]).toHaveProperty('error');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('stop', () => {
|
|
94
|
+
it('should transition to offline state', () => {
|
|
95
|
+
connectionManager.stop();
|
|
96
|
+
|
|
97
|
+
expect(connectionManager.getState()).toBe('offline');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should stop health check timer', async () => {
|
|
101
|
+
await connectionManager.start();
|
|
102
|
+
const healthCheckCount = (mockClient.healthCheck as any).mock.calls.length;
|
|
103
|
+
|
|
104
|
+
connectionManager.stop();
|
|
105
|
+
|
|
106
|
+
// Wait longer than health check interval
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
108
|
+
|
|
109
|
+
// Health check should not have been called again
|
|
110
|
+
expect((mockClient.healthCheck as any).mock.calls.length).toBe(healthCheckCount);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('reconnection logic', () => {
|
|
115
|
+
it('should attempt to reconnect on health check failure', async () => {
|
|
116
|
+
let callCount = 0;
|
|
117
|
+
mockClient.healthCheck = vi.fn().mockImplementation(() => {
|
|
118
|
+
callCount++;
|
|
119
|
+
if (callCount === 1) {
|
|
120
|
+
return Promise.reject(new Error('Failed'));
|
|
121
|
+
}
|
|
122
|
+
return Promise.resolve(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const reconnectingListener = vi.fn();
|
|
126
|
+
connectionManager.on('reconnecting', reconnectingListener);
|
|
127
|
+
|
|
128
|
+
await connectionManager.start();
|
|
129
|
+
|
|
130
|
+
// Wait for reconnect attempt
|
|
131
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
132
|
+
|
|
133
|
+
expect(reconnectingListener).toHaveBeenCalled();
|
|
134
|
+
expect(reconnectingListener.mock.calls[0][0]).toHaveProperty('attempt');
|
|
135
|
+
expect(reconnectingListener.mock.calls[0][0]).toHaveProperty('delay');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should use exponential backoff for reconnection', async () => {
|
|
139
|
+
mockClient.healthCheck = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
140
|
+
|
|
141
|
+
const reconnectingListener = vi.fn();
|
|
142
|
+
connectionManager.on('reconnecting', reconnectingListener);
|
|
143
|
+
|
|
144
|
+
await connectionManager.start();
|
|
145
|
+
|
|
146
|
+
// Wait for multiple reconnect attempts
|
|
147
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
148
|
+
|
|
149
|
+
const delays = reconnectingListener.mock.calls.map(call => call[0].delay);
|
|
150
|
+
|
|
151
|
+
// Delays should increase exponentially
|
|
152
|
+
for (let i = 1; i < delays.length; i++) {
|
|
153
|
+
expect(delays[i]).toBeGreaterThan(delays[i - 1]);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should emit maxReconnectAttemptsReached after max attempts', async () => {
|
|
158
|
+
mockClient.healthCheck = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
159
|
+
|
|
160
|
+
const maxAttemptsListener = vi.fn();
|
|
161
|
+
connectionManager.on('maxReconnectAttemptsReached', maxAttemptsListener);
|
|
162
|
+
|
|
163
|
+
await connectionManager.start();
|
|
164
|
+
|
|
165
|
+
// Wait for all reconnect attempts
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
167
|
+
|
|
168
|
+
expect(maxAttemptsListener).toHaveBeenCalled();
|
|
169
|
+
expect(connectionManager.getState()).toBe('offline');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should reset reconnect attempts on successful connection', async () => {
|
|
173
|
+
let callCount = 0;
|
|
174
|
+
mockClient.healthCheck = vi.fn().mockImplementation(() => {
|
|
175
|
+
callCount++;
|
|
176
|
+
// Fail first two, then succeed
|
|
177
|
+
if (callCount <= 2) {
|
|
178
|
+
return Promise.reject(new Error('Failed'));
|
|
179
|
+
}
|
|
180
|
+
return Promise.resolve(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await connectionManager.start();
|
|
184
|
+
|
|
185
|
+
// Wait for reconnection
|
|
186
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
187
|
+
|
|
188
|
+
expect(connectionManager.getState()).toBe('connected');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('execute', () => {
|
|
193
|
+
it('should execute function when connected', async () => {
|
|
194
|
+
await connectionManager.start();
|
|
195
|
+
|
|
196
|
+
const testFn = vi.fn().mockResolvedValue('result');
|
|
197
|
+
const result = await connectionManager.execute(testFn);
|
|
198
|
+
|
|
199
|
+
expect(result).toBe('result');
|
|
200
|
+
expect(testFn).toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should throw error when not connected', async () => {
|
|
204
|
+
const testFn = vi.fn();
|
|
205
|
+
|
|
206
|
+
await expect(connectionManager.execute(testFn)).rejects.toThrow('QwickBrain unavailable');
|
|
207
|
+
expect(testFn).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should record failure if execution throws', async () => {
|
|
211
|
+
await connectionManager.start();
|
|
212
|
+
|
|
213
|
+
const testFn = vi.fn().mockRejectedValue(new Error('Execution failed'));
|
|
214
|
+
|
|
215
|
+
await expect(connectionManager.execute(testFn)).rejects.toThrow('Execution failed');
|
|
216
|
+
|
|
217
|
+
// Should transition to reconnecting after failure
|
|
218
|
+
expect(connectionManager.getState()).toBe('reconnecting');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should wait for state transitions to complete (race condition test)', async () => {
|
|
222
|
+
await connectionManager.start();
|
|
223
|
+
|
|
224
|
+
// Simulate rapid state changes
|
|
225
|
+
connectionManager.setState('disconnected');
|
|
226
|
+
connectionManager.setState('connected');
|
|
227
|
+
|
|
228
|
+
const testFn = vi.fn().mockResolvedValue('result');
|
|
229
|
+
|
|
230
|
+
// This should wait for state transitions to settle
|
|
231
|
+
const result = await connectionManager.execute(testFn);
|
|
232
|
+
|
|
233
|
+
expect(result).toBe('result');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('recordFailure', () => {
|
|
238
|
+
it('should transition to reconnecting when connected', () => {
|
|
239
|
+
connectionManager['state'] = 'connected';
|
|
240
|
+
|
|
241
|
+
connectionManager.recordFailure();
|
|
242
|
+
|
|
243
|
+
// After failure, it immediately schedules reconnect
|
|
244
|
+
expect(connectionManager.getState()).toBe('reconnecting');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should not affect offline state', () => {
|
|
248
|
+
connectionManager.stop();
|
|
249
|
+
|
|
250
|
+
connectionManager.recordFailure();
|
|
251
|
+
|
|
252
|
+
expect(connectionManager.getState()).toBe('offline');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { createDatabase, runMigrations } from '../../db/client.js';
|
|
6
|
+
import { ProxyServer } from '../proxy-server.js';
|
|
7
|
+
import type { Config } from '../../types/config.js';
|
|
8
|
+
|
|
9
|
+
// Mock MCP SDK
|
|
10
|
+
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
11
|
+
Client: vi.fn().mockImplementation(() => ({
|
|
12
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
callTool: vi.fn(),
|
|
15
|
+
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
16
|
+
})),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
|
|
20
|
+
Server: vi.fn().mockImplementation(() => ({
|
|
21
|
+
setRequestHandler: vi.fn(),
|
|
22
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
})),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
|
28
|
+
StdioServerTransport: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
|
|
32
|
+
SSEClientTransport: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe('ProxyServer', () => {
|
|
36
|
+
let tmpDir: string;
|
|
37
|
+
let config: Config;
|
|
38
|
+
let db: ReturnType<typeof createDatabase>['db'];
|
|
39
|
+
let server: ProxyServer;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'proxy-test-'));
|
|
43
|
+
const dbResult = createDatabase(tmpDir);
|
|
44
|
+
db = dbResult.db;
|
|
45
|
+
|
|
46
|
+
// Run migrations to create tables
|
|
47
|
+
runMigrations(db);
|
|
48
|
+
|
|
49
|
+
config = {
|
|
50
|
+
qwickbrain: {
|
|
51
|
+
mode: 'sse',
|
|
52
|
+
url: 'http://test.local:3000',
|
|
53
|
+
},
|
|
54
|
+
cache: {
|
|
55
|
+
dir: tmpDir,
|
|
56
|
+
ttl: {
|
|
57
|
+
workflows: 3600,
|
|
58
|
+
rules: 3600,
|
|
59
|
+
documents: 1800,
|
|
60
|
+
memories: 900,
|
|
61
|
+
},
|
|
62
|
+
preload: [],
|
|
63
|
+
},
|
|
64
|
+
connection: {
|
|
65
|
+
healthCheckInterval: 30000,
|
|
66
|
+
timeout: 5000,
|
|
67
|
+
maxReconnectAttempts: 5,
|
|
68
|
+
reconnectBackoff: {
|
|
69
|
+
initial: 1000,
|
|
70
|
+
multiplier: 2,
|
|
71
|
+
max: 30000,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
sync: {
|
|
75
|
+
interval: 60000,
|
|
76
|
+
batchSize: 10,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
server = new ProxyServer(db, config);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
await server.stop();
|
|
85
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('initialization', () => {
|
|
89
|
+
it('should initialize with all components', () => {
|
|
90
|
+
expect(server['server']).toBeDefined();
|
|
91
|
+
expect(server['connectionManager']).toBeDefined();
|
|
92
|
+
expect(server['cacheManager']).toBeDefined();
|
|
93
|
+
expect(server['qwickbrainClient']).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should set up MCP request handlers', () => {
|
|
97
|
+
const setRequestHandlerMock = server['server'].setRequestHandler;
|
|
98
|
+
|
|
99
|
+
// Should have set up handlers for ListTools and CallTool
|
|
100
|
+
expect(setRequestHandlerMock).toHaveBeenCalled();
|
|
101
|
+
expect((setRequestHandlerMock as any).mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('cache cleanup on startup', () => {
|
|
106
|
+
it('should clean up expired cache entries on start', async () => {
|
|
107
|
+
// Add some expired entries
|
|
108
|
+
const shortTTLConfig = { ...config };
|
|
109
|
+
shortTTLConfig.cache.ttl = {
|
|
110
|
+
workflows: 0,
|
|
111
|
+
rules: 0,
|
|
112
|
+
documents: 0,
|
|
113
|
+
memories: 0,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const tempServer = new ProxyServer(db, shortTTLConfig);
|
|
117
|
+
|
|
118
|
+
await tempServer['cacheManager'].setDocument('workflow', 'test', 'content');
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
120
|
+
|
|
121
|
+
// Verify entry exists before cleanup
|
|
122
|
+
const beforeCleanup = await tempServer['cacheManager'].getDocument('workflow', 'test');
|
|
123
|
+
expect(beforeCleanup).not.toBeNull();
|
|
124
|
+
|
|
125
|
+
await tempServer.start();
|
|
126
|
+
|
|
127
|
+
// Entry should be removed after cleanup
|
|
128
|
+
const afterCleanup = await tempServer['cacheManager'].getDocument('workflow', 'test');
|
|
129
|
+
expect(afterCleanup).toBeNull();
|
|
130
|
+
|
|
131
|
+
await tempServer.stop();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('graceful degradation', () => {
|
|
136
|
+
it('should serve stale cache when disconnected', async () => {
|
|
137
|
+
// Create server with short TTL to make cache expire
|
|
138
|
+
const shortTTLConfig = { ...config };
|
|
139
|
+
shortTTLConfig.cache.ttl = {
|
|
140
|
+
workflows: 0,
|
|
141
|
+
rules: 0,
|
|
142
|
+
documents: 0,
|
|
143
|
+
memories: 0,
|
|
144
|
+
};
|
|
145
|
+
const tempServer = new ProxyServer(db, shortTTLConfig);
|
|
146
|
+
|
|
147
|
+
// Add item to cache
|
|
148
|
+
await tempServer['cacheManager'].setDocument('workflow', 'test', 'cached content');
|
|
149
|
+
|
|
150
|
+
// Wait for cache to expire
|
|
151
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
152
|
+
|
|
153
|
+
// Simulate disconnected state
|
|
154
|
+
tempServer['connectionManager']['state'] = 'disconnected';
|
|
155
|
+
|
|
156
|
+
const result = await tempServer['handleGetDocument']('workflow', 'test');
|
|
157
|
+
|
|
158
|
+
expect((result.data as any).content).toBe('cached content');
|
|
159
|
+
expect(result._metadata.source).toBe('stale_cache');
|
|
160
|
+
|
|
161
|
+
await tempServer.stop();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should return error when no cache available and disconnected', async () => {
|
|
165
|
+
// Simulate disconnected state with no cache
|
|
166
|
+
server['connectionManager']['state'] = 'disconnected';
|
|
167
|
+
|
|
168
|
+
const result = await server['handleGetDocument']('workflow', 'non-existent');
|
|
169
|
+
|
|
170
|
+
expect(result.error).toBeDefined();
|
|
171
|
+
expect(result.error?.code).toBe('UNAVAILABLE');
|
|
172
|
+
expect(result.error?.message).toContain('QwickBrain unavailable');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('metadata', () => {
|
|
177
|
+
it('should include correct metadata for cache hit', async () => {
|
|
178
|
+
await server['cacheManager'].setDocument('workflow', 'test', 'content');
|
|
179
|
+
|
|
180
|
+
// Simulate connected state
|
|
181
|
+
server['connectionManager']['state'] = 'connected';
|
|
182
|
+
|
|
183
|
+
const result = await server['handleGetDocument']('workflow', 'test');
|
|
184
|
+
|
|
185
|
+
expect(result._metadata.source).toBe('cache');
|
|
186
|
+
expect(result._metadata.age_seconds).toBeDefined();
|
|
187
|
+
expect(typeof result._metadata.age_seconds).toBe('number');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should create metadata with correct source', () => {
|
|
191
|
+
const cacheMeta = server['createMetadata']('cache', 100);
|
|
192
|
+
expect(cacheMeta).toEqual({
|
|
193
|
+
source: 'cache',
|
|
194
|
+
age_seconds: 100,
|
|
195
|
+
status: server['connectionManager'].getState(),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const liveMeta = server['createMetadata']('live');
|
|
199
|
+
expect(liveMeta).toEqual({
|
|
200
|
+
source: 'live',
|
|
201
|
+
status: server['connectionManager'].getState(),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const staleMeta = server['createMetadata']('stale_cache', 500);
|
|
205
|
+
expect(staleMeta).toEqual({
|
|
206
|
+
source: 'stale_cache',
|
|
207
|
+
age_seconds: 500,
|
|
208
|
+
status: server['connectionManager'].getState(),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('error handling', () => {
|
|
214
|
+
it('should handle QwickBrain errors gracefully', async () => {
|
|
215
|
+
// Mock client to throw error
|
|
216
|
+
server['qwickbrainClient'].getDocument = vi.fn().mockRejectedValue(
|
|
217
|
+
new Error('Network error')
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Simulate connected state
|
|
221
|
+
server['connectionManager']['state'] = 'connected';
|
|
222
|
+
|
|
223
|
+
const result = await server['handleGetDocument']('workflow', 'test');
|
|
224
|
+
|
|
225
|
+
// Should fall back to error response when remote fails and no cache
|
|
226
|
+
expect(result.error).toBeDefined();
|
|
227
|
+
expect(result.error?.code).toBe('UNAVAILABLE');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('project scoping', () => {
|
|
232
|
+
it('should handle project-scoped documents', async () => {
|
|
233
|
+
await server['cacheManager'].setDocument(
|
|
234
|
+
'design',
|
|
235
|
+
'test-design',
|
|
236
|
+
'design content',
|
|
237
|
+
'my-project'
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
server['connectionManager']['state'] = 'connected';
|
|
241
|
+
|
|
242
|
+
const result = await server['handleGetDocument']('design', 'test-design', 'my-project');
|
|
243
|
+
|
|
244
|
+
expect((result.data as any).content).toBe('design content');
|
|
245
|
+
expect((result.data as any).project).toBe('my-project');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should distinguish global vs project-scoped items', async () => {
|
|
249
|
+
await server['cacheManager'].setDocument('rule', 'test', 'global rule');
|
|
250
|
+
await server['cacheManager'].setDocument('rule', 'test', 'project rule', 'proj1');
|
|
251
|
+
|
|
252
|
+
server['connectionManager']['state'] = 'connected';
|
|
253
|
+
|
|
254
|
+
const globalResult = await server['handleGetDocument']('rule', 'test');
|
|
255
|
+
const projectResult = await server['handleGetDocument']('rule', 'test', 'proj1');
|
|
256
|
+
|
|
257
|
+
expect((globalResult.data as any).content).toBe('global rule');
|
|
258
|
+
expect((projectResult.data as any).content).toBe('project rule');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|