@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.1 → 1.0.0-beta.10
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/README.md +169 -3
- package/dist/auth/AuthUtils.d.ts +12 -5
- package/dist/auth/AuthUtils.d.ts.map +1 -1
- package/dist/auth/AuthUtils.js +80 -25
- package/dist/auth/AuthUtils.js.map +1 -1
- package/dist/auth/AuthUtils.test.js +161 -117
- package/dist/auth/AuthUtils.test.js.map +1 -1
- package/dist/function/GlobalToolFunction.d.ts +5 -3
- package/dist/function/GlobalToolFunction.d.ts.map +1 -1
- package/dist/function/GlobalToolFunction.js +32 -8
- package/dist/function/GlobalToolFunction.js.map +1 -1
- package/dist/function/GlobalToolFunction.test.js +73 -12
- package/dist/function/GlobalToolFunction.test.js.map +1 -1
- package/dist/function/ToolFunction.d.ts +11 -4
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +45 -9
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +278 -11
- package/dist/function/ToolFunction.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/logging/ToolLogger.d.ts +42 -0
- package/dist/logging/ToolLogger.d.ts.map +1 -0
- package/dist/logging/ToolLogger.js +255 -0
- package/dist/logging/ToolLogger.js.map +1 -0
- package/dist/logging/ToolLogger.test.d.ts +2 -0
- package/dist/logging/ToolLogger.test.d.ts.map +1 -0
- package/dist/logging/ToolLogger.test.js +864 -0
- package/dist/logging/ToolLogger.test.js.map +1 -0
- package/dist/service/Service.d.ts +88 -2
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js +228 -39
- package/dist/service/Service.js.map +1 -1
- package/dist/service/Service.test.js +558 -22
- package/dist/service/Service.test.js.map +1 -1
- package/dist/types/Models.d.ts +7 -1
- package/dist/types/Models.d.ts.map +1 -1
- package/dist/types/Models.js +5 -1
- package/dist/types/Models.js.map +1 -1
- package/dist/types/ToolError.d.ts +72 -0
- package/dist/types/ToolError.d.ts.map +1 -0
- package/dist/types/ToolError.js +107 -0
- package/dist/types/ToolError.js.map +1 -0
- package/dist/types/ToolError.test.d.ts +2 -0
- package/dist/types/ToolError.test.d.ts.map +1 -0
- package/dist/types/ToolError.test.js +185 -0
- package/dist/types/ToolError.test.js.map +1 -0
- package/dist/validation/ParameterValidator.d.ts +31 -0
- package/dist/validation/ParameterValidator.d.ts.map +1 -0
- package/dist/validation/ParameterValidator.js +129 -0
- package/dist/validation/ParameterValidator.js.map +1 -0
- package/dist/validation/ParameterValidator.test.d.ts +2 -0
- package/dist/validation/ParameterValidator.test.d.ts.map +1 -0
- package/dist/validation/ParameterValidator.test.js +323 -0
- package/dist/validation/ParameterValidator.test.js.map +1 -0
- package/package.json +3 -3
- package/src/auth/AuthUtils.test.ts +176 -157
- package/src/auth/AuthUtils.ts +96 -33
- package/src/function/GlobalToolFunction.test.ts +78 -14
- package/src/function/GlobalToolFunction.ts +46 -11
- package/src/function/ToolFunction.test.ts +298 -13
- package/src/function/ToolFunction.ts +61 -13
- package/src/index.ts +2 -1
- package/src/logging/ToolLogger.test.ts +1020 -0
- package/src/logging/ToolLogger.ts +292 -0
- package/src/service/Service.test.ts +712 -28
- package/src/service/Service.ts +288 -38
- package/src/types/Models.ts +8 -1
- package/src/types/ToolError.test.ts +222 -0
- package/src/types/ToolError.ts +125 -0
- package/src/validation/ParameterValidator.test.ts +371 -0
- package/src/validation/ParameterValidator.ts +150 -0
|
@@ -1,27 +1,80 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
3
4
|
const Service_1 = require("./Service");
|
|
4
5
|
const Models_1 = require("../types/Models");
|
|
6
|
+
const ToolError_1 = require("../types/ToolError");
|
|
5
7
|
const ToolFunction_1 = require("../function/ToolFunction");
|
|
6
8
|
const app_sdk_1 = require("@zaiusinc/app-sdk");
|
|
7
9
|
// Mock the logger and other app-sdk exports
|
|
8
|
-
jest.mock('@zaiusinc/app-sdk', () =>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
jest.mock('@zaiusinc/app-sdk', () => {
|
|
11
|
+
const mockKvStoreInstance = {
|
|
12
|
+
get: jest.fn(),
|
|
13
|
+
put: jest.fn(),
|
|
14
|
+
patch: jest.fn(),
|
|
15
|
+
delete: jest.fn()
|
|
16
|
+
};
|
|
17
|
+
const mockApp = {
|
|
18
|
+
storage: {
|
|
19
|
+
kvStore: mockKvStoreInstance
|
|
20
|
+
},
|
|
21
|
+
Response: jest.fn().mockImplementation((status, data, headers) => ({
|
|
22
|
+
status,
|
|
23
|
+
data,
|
|
24
|
+
bodyJSON: data,
|
|
25
|
+
bodyAsU8Array: new Uint8Array(),
|
|
26
|
+
headers: headers || { get: jest.fn(), has: jest.fn(), set: jest.fn() }
|
|
27
|
+
})),
|
|
28
|
+
Function: class {
|
|
29
|
+
request;
|
|
30
|
+
constructor(request) {
|
|
31
|
+
this.request = request;
|
|
32
|
+
}
|
|
16
33
|
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
...mockApp,
|
|
37
|
+
default: mockApp,
|
|
38
|
+
App: mockApp,
|
|
39
|
+
getAppContext: jest.fn().mockReturnValue({
|
|
40
|
+
manifest: { meta: { version: 'app-v1' } }
|
|
41
|
+
}),
|
|
42
|
+
logger: {
|
|
43
|
+
error: jest.fn(),
|
|
44
|
+
info: jest.fn(),
|
|
45
|
+
warn: jest.fn(),
|
|
46
|
+
debug: jest.fn()
|
|
47
|
+
},
|
|
48
|
+
Headers: class {
|
|
49
|
+
constructor(headers = []) {
|
|
50
|
+
this.headers = {};
|
|
51
|
+
headers.forEach(([name, value]) => {
|
|
52
|
+
this.headers[name.toLowerCase()] = value;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
headers;
|
|
56
|
+
set(name, value) {
|
|
57
|
+
this.headers[name.toLowerCase()] = value;
|
|
58
|
+
}
|
|
59
|
+
get(name) {
|
|
60
|
+
return this.headers[name.toLowerCase()];
|
|
61
|
+
}
|
|
62
|
+
has(name) {
|
|
63
|
+
return name.toLowerCase() in this.headers;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
Response: jest.fn().mockImplementation((status, data, headers) => ({
|
|
67
|
+
status,
|
|
68
|
+
data,
|
|
69
|
+
bodyJSON: data,
|
|
70
|
+
bodyAsU8Array: new Uint8Array(),
|
|
71
|
+
headers: headers || { get: jest.fn(), has: jest.fn(), set: jest.fn() }
|
|
72
|
+
}))
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
// Get the mocked kvStore for use in tests
|
|
76
|
+
const { storage } = jest.requireMock('@zaiusinc/app-sdk');
|
|
77
|
+
const mockKvStore = storage.kvStore;
|
|
25
78
|
describe('ToolsService', () => {
|
|
26
79
|
let mockTool;
|
|
27
80
|
let mockInteraction;
|
|
@@ -243,12 +296,19 @@ describe('ToolsService', () => {
|
|
|
243
296
|
expect(mockTool.handler).toHaveBeenCalledWith(mockToolFunction, // functionContext
|
|
244
297
|
{ param1: 'test-value' }, undefined);
|
|
245
298
|
});
|
|
246
|
-
it('should return 500 error when tool handler throws
|
|
299
|
+
it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
|
|
247
300
|
const errorMessage = 'Tool execution failed';
|
|
248
301
|
jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
249
302
|
const mockRequest = createMockRequest();
|
|
250
303
|
const response = await Service_1.toolsService.processRequest(mockRequest, mockToolFunction);
|
|
251
304
|
expect(response.status).toBe(500);
|
|
305
|
+
expect(response.bodyJSON).toEqual({
|
|
306
|
+
title: 'Internal Server Error',
|
|
307
|
+
status: 500,
|
|
308
|
+
detail: errorMessage,
|
|
309
|
+
instance: mockTool.endpoint
|
|
310
|
+
});
|
|
311
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
252
312
|
expect(app_sdk_1.logger.error).toHaveBeenCalledWith(`Error in function ${mockTool.name}:`, expect.any(Error));
|
|
253
313
|
});
|
|
254
314
|
it('should return 500 error with generic message when error has no message', async () => {
|
|
@@ -256,6 +316,55 @@ describe('ToolsService', () => {
|
|
|
256
316
|
const mockRequest = createMockRequest();
|
|
257
317
|
const response = await Service_1.toolsService.processRequest(mockRequest, mockToolFunction);
|
|
258
318
|
expect(response.status).toBe(500);
|
|
319
|
+
expect(response.bodyJSON).toEqual({
|
|
320
|
+
title: 'Internal Server Error',
|
|
321
|
+
status: 500,
|
|
322
|
+
detail: 'An unexpected error occurred',
|
|
323
|
+
instance: mockTool.endpoint
|
|
324
|
+
});
|
|
325
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
326
|
+
});
|
|
327
|
+
it('should return custom status code when tool handler throws ToolError', async () => {
|
|
328
|
+
const toolError = new ToolError_1.ToolError('Resource not found', 404, 'The requested task does not exist');
|
|
329
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
330
|
+
const mockRequest = createMockRequest();
|
|
331
|
+
const response = await Service_1.toolsService.processRequest(mockRequest, mockToolFunction);
|
|
332
|
+
expect(response.status).toBe(404);
|
|
333
|
+
expect(response.bodyJSON).toEqual({
|
|
334
|
+
title: 'Resource not found',
|
|
335
|
+
status: 404,
|
|
336
|
+
detail: 'The requested task does not exist',
|
|
337
|
+
instance: mockTool.endpoint
|
|
338
|
+
});
|
|
339
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
340
|
+
expect(app_sdk_1.logger.error).toHaveBeenCalledWith(`Error in function ${mockTool.name}:`, expect.any(ToolError_1.ToolError));
|
|
341
|
+
});
|
|
342
|
+
it('should return ToolError without detail field when detail is not provided', async () => {
|
|
343
|
+
const toolError = new ToolError_1.ToolError('Bad request', 400);
|
|
344
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
345
|
+
const mockRequest = createMockRequest();
|
|
346
|
+
const response = await Service_1.toolsService.processRequest(mockRequest, mockToolFunction);
|
|
347
|
+
expect(response.status).toBe(400);
|
|
348
|
+
expect(response.bodyJSON).toEqual({
|
|
349
|
+
title: 'Bad request',
|
|
350
|
+
status: 400,
|
|
351
|
+
instance: mockTool.endpoint
|
|
352
|
+
});
|
|
353
|
+
expect(response.bodyJSON).not.toHaveProperty('detail');
|
|
354
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
355
|
+
});
|
|
356
|
+
it('should default to 500 when ToolError is created without status', async () => {
|
|
357
|
+
const toolError = new ToolError_1.ToolError('Database error');
|
|
358
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
359
|
+
const mockRequest = createMockRequest();
|
|
360
|
+
const response = await Service_1.toolsService.processRequest(mockRequest, mockToolFunction);
|
|
361
|
+
expect(response.status).toBe(500);
|
|
362
|
+
expect(response.bodyJSON).toEqual({
|
|
363
|
+
title: 'Database error',
|
|
364
|
+
status: 500,
|
|
365
|
+
instance: mockTool.endpoint
|
|
366
|
+
});
|
|
367
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
259
368
|
});
|
|
260
369
|
});
|
|
261
370
|
describe('interaction execution', () => {
|
|
@@ -321,7 +430,7 @@ describe('ToolsService', () => {
|
|
|
321
430
|
auth: authData
|
|
322
431
|
}, authData);
|
|
323
432
|
});
|
|
324
|
-
it('should return 500 error when interaction handler throws
|
|
433
|
+
it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
|
|
325
434
|
const errorMessage = 'Interaction execution failed';
|
|
326
435
|
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
327
436
|
const interactionRequest = createMockRequest({
|
|
@@ -330,8 +439,33 @@ describe('ToolsService', () => {
|
|
|
330
439
|
});
|
|
331
440
|
const response = await Service_1.toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
332
441
|
expect(response.status).toBe(500);
|
|
442
|
+
expect(response.bodyJSON).toEqual({
|
|
443
|
+
title: 'Internal Server Error',
|
|
444
|
+
status: 500,
|
|
445
|
+
detail: errorMessage,
|
|
446
|
+
instance: mockInteraction.endpoint
|
|
447
|
+
});
|
|
448
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
333
449
|
expect(app_sdk_1.logger.error).toHaveBeenCalledWith(`Error in function ${mockInteraction.name}:`, expect.any(Error));
|
|
334
450
|
});
|
|
451
|
+
it('should return custom status code when interaction handler throws ToolError', async () => {
|
|
452
|
+
const toolError = new ToolError_1.ToolError('Webhook validation failed', 400, 'Invalid signature');
|
|
453
|
+
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(toolError);
|
|
454
|
+
const interactionRequest = createMockRequest({
|
|
455
|
+
path: '/test-interaction',
|
|
456
|
+
bodyJSON: { data: { param1: 'test-value' } }
|
|
457
|
+
});
|
|
458
|
+
const response = await Service_1.toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
459
|
+
expect(response.status).toBe(400);
|
|
460
|
+
expect(response.bodyJSON).toEqual({
|
|
461
|
+
title: 'Webhook validation failed',
|
|
462
|
+
status: 400,
|
|
463
|
+
detail: 'Invalid signature',
|
|
464
|
+
instance: mockInteraction.endpoint
|
|
465
|
+
});
|
|
466
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
467
|
+
expect(app_sdk_1.logger.error).toHaveBeenCalledWith(`Error in function ${mockInteraction.name}:`, expect.any(ToolError_1.ToolError));
|
|
468
|
+
});
|
|
335
469
|
});
|
|
336
470
|
describe('error cases', () => {
|
|
337
471
|
it('should return 404 when no matching tool or interaction is found', async () => {
|
|
@@ -353,24 +487,42 @@ describe('ToolsService', () => {
|
|
|
353
487
|
});
|
|
354
488
|
describe('edge cases', () => {
|
|
355
489
|
it('should handle request with null bodyJSON', async () => {
|
|
356
|
-
|
|
490
|
+
// Create a tool without required parameters
|
|
491
|
+
const toolWithoutRequiredParams = {
|
|
492
|
+
name: 'no_required_params_tool',
|
|
493
|
+
description: 'Tool without required parameters',
|
|
494
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
495
|
+
parameters: [], // No parameters defined
|
|
496
|
+
endpoint: '/no-required-params-tool'
|
|
497
|
+
};
|
|
498
|
+
Service_1.toolsService.registerTool(toolWithoutRequiredParams.name, toolWithoutRequiredParams.description, toolWithoutRequiredParams.handler, toolWithoutRequiredParams.parameters, toolWithoutRequiredParams.endpoint);
|
|
357
499
|
const requestWithNullBody = createMockRequest({
|
|
500
|
+
path: '/no-required-params-tool',
|
|
358
501
|
bodyJSON: null,
|
|
359
502
|
body: null
|
|
360
503
|
});
|
|
361
504
|
const response = await Service_1.toolsService.processRequest(requestWithNullBody, mockToolFunction);
|
|
362
505
|
expect(response.status).toBe(200);
|
|
363
|
-
expect(
|
|
506
|
+
expect(toolWithoutRequiredParams.handler).toHaveBeenCalledWith(mockToolFunction, null, undefined);
|
|
364
507
|
});
|
|
365
508
|
it('should handle request with undefined bodyJSON', async () => {
|
|
366
|
-
|
|
509
|
+
// Create a tool without required parameters
|
|
510
|
+
const toolWithoutRequiredParams = {
|
|
511
|
+
name: 'no_required_params_tool_2',
|
|
512
|
+
description: 'Tool without required parameters',
|
|
513
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
514
|
+
parameters: [], // No parameters defined
|
|
515
|
+
endpoint: '/no-required-params-tool-2'
|
|
516
|
+
};
|
|
517
|
+
Service_1.toolsService.registerTool(toolWithoutRequiredParams.name, toolWithoutRequiredParams.description, toolWithoutRequiredParams.handler, toolWithoutRequiredParams.parameters, toolWithoutRequiredParams.endpoint);
|
|
367
518
|
const requestWithUndefinedBody = createMockRequest({
|
|
519
|
+
path: '/no-required-params-tool-2',
|
|
368
520
|
bodyJSON: undefined,
|
|
369
521
|
body: undefined
|
|
370
522
|
});
|
|
371
523
|
const response = await Service_1.toolsService.processRequest(requestWithUndefinedBody, mockToolFunction);
|
|
372
524
|
expect(response.status).toBe(200);
|
|
373
|
-
expect(
|
|
525
|
+
expect(toolWithoutRequiredParams.handler).toHaveBeenCalledWith(mockToolFunction, undefined, undefined);
|
|
374
526
|
});
|
|
375
527
|
it('should extract auth data from bodyJSON when body exists', async () => {
|
|
376
528
|
Service_1.toolsService.registerTool(mockTool.name, mockTool.description, mockTool.handler, mockTool.parameters, mockTool.endpoint);
|
|
@@ -422,6 +574,390 @@ describe('ToolsService', () => {
|
|
|
422
574
|
{ param1: 'test-value' }, authData);
|
|
423
575
|
});
|
|
424
576
|
});
|
|
577
|
+
describe('parameter validation', () => {
|
|
578
|
+
beforeEach(() => {
|
|
579
|
+
jest.clearAllMocks();
|
|
580
|
+
});
|
|
581
|
+
it('should validate parameters and return 400 for invalid types', async () => {
|
|
582
|
+
// Register a tool with specific parameter types
|
|
583
|
+
const toolWithTypedParams = {
|
|
584
|
+
name: 'typed_tool',
|
|
585
|
+
description: 'Tool with typed parameters',
|
|
586
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
587
|
+
parameters: [
|
|
588
|
+
new Models_1.Parameter('name', Models_1.ParameterType.String, 'User name', true),
|
|
589
|
+
new Models_1.Parameter('age', Models_1.ParameterType.Integer, 'User age', true),
|
|
590
|
+
new Models_1.Parameter('active', Models_1.ParameterType.Boolean, 'Is active', false)
|
|
591
|
+
],
|
|
592
|
+
endpoint: '/typed-tool'
|
|
593
|
+
};
|
|
594
|
+
Service_1.toolsService.registerTool(toolWithTypedParams.name, toolWithTypedParams.description, toolWithTypedParams.handler, toolWithTypedParams.parameters, toolWithTypedParams.endpoint);
|
|
595
|
+
// Send invalid parameter types
|
|
596
|
+
const invalidRequest = createMockRequest({
|
|
597
|
+
path: '/typed-tool',
|
|
598
|
+
bodyJSON: {
|
|
599
|
+
parameters: {
|
|
600
|
+
name: 123, // should be string
|
|
601
|
+
age: '25', // should be integer
|
|
602
|
+
active: 'true' // should be boolean
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
const response = await Service_1.toolsService.processRequest(invalidRequest, mockToolFunction);
|
|
607
|
+
expect(response.status).toBe(400);
|
|
608
|
+
// Expect RFC 9457 Problem Details format
|
|
609
|
+
expect(response.bodyJSON).toHaveProperty('title', 'One or more validation errors occurred.');
|
|
610
|
+
expect(response.bodyJSON).toHaveProperty('status', 400);
|
|
611
|
+
expect(response.bodyJSON).toHaveProperty('detail', 'See \'errors\' field for details.');
|
|
612
|
+
expect(response.bodyJSON).toHaveProperty('instance', '/typed-tool');
|
|
613
|
+
expect(response.bodyJSON).toHaveProperty('errors');
|
|
614
|
+
expect(response.bodyJSON.errors).toHaveLength(3);
|
|
615
|
+
// Check error structure - field and message
|
|
616
|
+
const errors = response.bodyJSON.errors;
|
|
617
|
+
expect(errors[0]).toHaveProperty('field', 'name');
|
|
618
|
+
expect(errors[0]).toHaveProperty('message', "Parameter 'name' must be a string, but received number");
|
|
619
|
+
// Check that the content type is set to application/problem+json for RFC 9457 compliance
|
|
620
|
+
expect(response.headers).toBeDefined();
|
|
621
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
622
|
+
// Verify the handler was not called
|
|
623
|
+
expect(toolWithTypedParams.handler).not.toHaveBeenCalled();
|
|
624
|
+
});
|
|
625
|
+
it('should skip validation for tools with no parameter definitions', async () => {
|
|
626
|
+
const toolWithoutParams = {
|
|
627
|
+
name: 'no_params_tool',
|
|
628
|
+
description: 'Tool without parameters',
|
|
629
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
630
|
+
parameters: [], // No parameters defined
|
|
631
|
+
endpoint: '/no-params-tool'
|
|
632
|
+
};
|
|
633
|
+
Service_1.toolsService.registerTool(toolWithoutParams.name, toolWithoutParams.description, toolWithoutParams.handler, toolWithoutParams.parameters, toolWithoutParams.endpoint);
|
|
634
|
+
// Send request with any data (should be ignored)
|
|
635
|
+
const request = createMockRequest({
|
|
636
|
+
path: '/no-params-tool',
|
|
637
|
+
bodyJSON: {
|
|
638
|
+
parameters: {
|
|
639
|
+
unexpected: 'value'
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
644
|
+
expect(response.status).toBe(200);
|
|
645
|
+
expect(toolWithoutParams.handler).toHaveBeenCalledWith(mockToolFunction, { unexpected: 'value' }, undefined);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
describe('Override functionality', () => {
|
|
650
|
+
beforeEach(() => {
|
|
651
|
+
// Reset KV store mocks
|
|
652
|
+
mockKvStore.get.mockReset();
|
|
653
|
+
mockKvStore.put.mockReset();
|
|
654
|
+
mockKvStore.patch.mockReset();
|
|
655
|
+
mockKvStore.delete.mockReset();
|
|
656
|
+
// Register some test tools for override testing
|
|
657
|
+
Service_1.toolsService.registerTool('search_tool', 'Search for information', jest.fn().mockResolvedValue({ result: 'search success' }), [new Models_1.Parameter('query', Models_1.ParameterType.String, 'Search query', true)], '/search');
|
|
658
|
+
Service_1.toolsService.registerTool('calculator', 'Perform calculations', jest.fn().mockResolvedValue({ result: 'calculation success' }), [
|
|
659
|
+
new Models_1.Parameter('operation', Models_1.ParameterType.String, 'Math operation', true),
|
|
660
|
+
new Models_1.Parameter('numbers', Models_1.ParameterType.String, 'Numbers to calculate', true)
|
|
661
|
+
], '/calculate');
|
|
662
|
+
// Mock ToolFunction constructor name for getAppVersionId and getFunctionName
|
|
663
|
+
Object.defineProperty(mockToolFunction.constructor, 'name', {
|
|
664
|
+
value: 'TestFunction',
|
|
665
|
+
configurable: true
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
describe('Discovery endpoint with overrides', () => {
|
|
669
|
+
it('should return original functions when no overrides exist', async () => {
|
|
670
|
+
// Mock KV store to return no overrides
|
|
671
|
+
mockKvStore.get.mockResolvedValue(null);
|
|
672
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
673
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
674
|
+
expect(response.status).toBe(200);
|
|
675
|
+
expect(mockKvStore.get).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides');
|
|
676
|
+
const responseData = response.bodyJSON;
|
|
677
|
+
expect(responseData.functions).toHaveLength(2);
|
|
678
|
+
const searchTool = responseData.functions.find((f) => f.name === 'search_tool');
|
|
679
|
+
expect(searchTool.description).toBe('Search for information');
|
|
680
|
+
const calculator = responseData.functions.find((f) => f.name === 'calculator');
|
|
681
|
+
expect(calculator.description).toBe('Perform calculations');
|
|
682
|
+
});
|
|
683
|
+
it('should apply overrides when they exist', async () => {
|
|
684
|
+
// Mock KV store to return overrides in the new map format
|
|
685
|
+
const storedOverrides = {
|
|
686
|
+
search_tool: {
|
|
687
|
+
name: 'search_tool',
|
|
688
|
+
description: 'Enhanced search with AI capabilities',
|
|
689
|
+
parameters: [
|
|
690
|
+
{
|
|
691
|
+
name: 'query',
|
|
692
|
+
description: 'AI-powered search query'
|
|
693
|
+
}
|
|
694
|
+
]
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
mockKvStore.get.mockResolvedValue(storedOverrides);
|
|
698
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
699
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
700
|
+
expect(response.status).toBe(200);
|
|
701
|
+
const responseData = response.bodyJSON;
|
|
702
|
+
expect(responseData.functions).toHaveLength(2);
|
|
703
|
+
// Check that search_tool has overridden description and parameters
|
|
704
|
+
const searchTool = responseData.functions.find((f) => f.name === 'search_tool');
|
|
705
|
+
expect(searchTool.description).toBe('Enhanced search with AI capabilities');
|
|
706
|
+
expect(searchTool.parameters).toHaveLength(1); // Only the original 'query' parameter
|
|
707
|
+
expect(searchTool.parameters[0].description).toBe('AI-powered search query');
|
|
708
|
+
// Check that calculator remains unchanged
|
|
709
|
+
const calculator = responseData.functions.find((f) => f.name === 'calculator');
|
|
710
|
+
expect(calculator.description).toBe('Perform calculations');
|
|
711
|
+
expect(calculator.parameters).toHaveLength(2);
|
|
712
|
+
});
|
|
713
|
+
it('should handle KV store errors gracefully', async () => {
|
|
714
|
+
// Mock KV store to throw an error
|
|
715
|
+
mockKvStore.get.mockRejectedValue(new Error('KV store unavailable'));
|
|
716
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
717
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
718
|
+
expect(response.status).toBe(200);
|
|
719
|
+
// Should return original functions despite the error
|
|
720
|
+
const responseData = response.bodyJSON;
|
|
721
|
+
expect(responseData.functions).toHaveLength(2);
|
|
722
|
+
});
|
|
723
|
+
it('should handle KV store errors gracefully when getting overrides fails', async () => {
|
|
724
|
+
// Mock KV store to throw an error when getting overrides
|
|
725
|
+
mockKvStore.get.mockRejectedValue(new Error('KV store connection failed'));
|
|
726
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
727
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
728
|
+
expect(response.status).toBe(200);
|
|
729
|
+
// Should return original functions despite the error
|
|
730
|
+
const responseData = response.bodyJSON;
|
|
731
|
+
expect(responseData.functions).toHaveLength(2);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
describe('PATCH /overrides endpoint', () => {
|
|
735
|
+
it('should save new overrides successfully', async () => {
|
|
736
|
+
const overrideData = {
|
|
737
|
+
functions: [
|
|
738
|
+
{
|
|
739
|
+
name: 'search_tool',
|
|
740
|
+
description: 'Enhanced search functionality',
|
|
741
|
+
parameters: [
|
|
742
|
+
{
|
|
743
|
+
name: 'query',
|
|
744
|
+
type: 'string',
|
|
745
|
+
description: 'Search query',
|
|
746
|
+
required: true
|
|
747
|
+
}
|
|
748
|
+
]
|
|
749
|
+
}
|
|
750
|
+
]
|
|
751
|
+
};
|
|
752
|
+
const request = createMockRequest({
|
|
753
|
+
path: '/overrides',
|
|
754
|
+
method: 'PATCH',
|
|
755
|
+
bodyJSON: overrideData,
|
|
756
|
+
body: JSON.stringify(overrideData)
|
|
757
|
+
});
|
|
758
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
759
|
+
expect(response.status).toBe(200);
|
|
760
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides', {
|
|
761
|
+
search_tool: {
|
|
762
|
+
name: 'search_tool',
|
|
763
|
+
description: 'Enhanced search functionality',
|
|
764
|
+
parameters: [
|
|
765
|
+
{
|
|
766
|
+
name: 'query',
|
|
767
|
+
type: 'string',
|
|
768
|
+
description: 'Search query',
|
|
769
|
+
required: true
|
|
770
|
+
}
|
|
771
|
+
]
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
it('should merge with existing overrides', async () => {
|
|
776
|
+
const newOverrideData = {
|
|
777
|
+
functions: [
|
|
778
|
+
{
|
|
779
|
+
name: 'new_tool',
|
|
780
|
+
description: 'New tool',
|
|
781
|
+
parameters: []
|
|
782
|
+
}
|
|
783
|
+
]
|
|
784
|
+
};
|
|
785
|
+
const request = createMockRequest({
|
|
786
|
+
path: '/overrides',
|
|
787
|
+
method: 'PATCH',
|
|
788
|
+
bodyJSON: newOverrideData,
|
|
789
|
+
body: JSON.stringify(newOverrideData)
|
|
790
|
+
});
|
|
791
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
792
|
+
expect(response.status).toBe(200);
|
|
793
|
+
// Verify that the new tool is saved using patch (which handles merging automatically)
|
|
794
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides', {
|
|
795
|
+
new_tool: {
|
|
796
|
+
name: 'new_tool',
|
|
797
|
+
description: 'New tool',
|
|
798
|
+
parameters: []
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
it('should update existing tool when saving override with same name', async () => {
|
|
803
|
+
const updatedOverrideData = {
|
|
804
|
+
functions: [
|
|
805
|
+
{
|
|
806
|
+
name: 'search_tool',
|
|
807
|
+
description: 'Updated description',
|
|
808
|
+
parameters: [
|
|
809
|
+
{
|
|
810
|
+
name: 'query',
|
|
811
|
+
type: 'string',
|
|
812
|
+
description: 'Search query',
|
|
813
|
+
required: true
|
|
814
|
+
}
|
|
815
|
+
]
|
|
816
|
+
}
|
|
817
|
+
]
|
|
818
|
+
};
|
|
819
|
+
const request = createMockRequest({
|
|
820
|
+
path: '/overrides',
|
|
821
|
+
method: 'PATCH',
|
|
822
|
+
bodyJSON: updatedOverrideData,
|
|
823
|
+
body: JSON.stringify(updatedOverrideData)
|
|
824
|
+
});
|
|
825
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
826
|
+
expect(response.status).toBe(200);
|
|
827
|
+
// Verify that the existing tool was updated using patch
|
|
828
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides', {
|
|
829
|
+
search_tool: {
|
|
830
|
+
name: 'search_tool',
|
|
831
|
+
description: 'Updated description',
|
|
832
|
+
parameters: [
|
|
833
|
+
{
|
|
834
|
+
name: 'query',
|
|
835
|
+
type: 'string',
|
|
836
|
+
description: 'Search query',
|
|
837
|
+
required: true
|
|
838
|
+
}
|
|
839
|
+
]
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
it('should handle KV store errors during save', async () => {
|
|
844
|
+
mockKvStore.patch.mockRejectedValue(new Error('KV store write error'));
|
|
845
|
+
const request = createMockRequest({
|
|
846
|
+
path: '/overrides',
|
|
847
|
+
method: 'PATCH',
|
|
848
|
+
bodyJSON: { functions: [] }
|
|
849
|
+
});
|
|
850
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
851
|
+
expect(response.status).toBe(500);
|
|
852
|
+
expect(app_sdk_1.logger.error).toHaveBeenCalledWith('Error saving tool overrides:', expect.any(Error));
|
|
853
|
+
});
|
|
854
|
+
it('should return 400 for invalid request body format', async () => {
|
|
855
|
+
const invalidRequest = createMockRequest({
|
|
856
|
+
path: '/overrides',
|
|
857
|
+
method: 'PATCH',
|
|
858
|
+
bodyJSON: { tools: [] } // Wrong property name, should be 'functions'
|
|
859
|
+
});
|
|
860
|
+
const response = await Service_1.toolsService.processRequest(invalidRequest, mockToolFunction);
|
|
861
|
+
expect(response.status).toBe(400);
|
|
862
|
+
expect(response.bodyJSON).toEqual({ error: 'Invalid request body. Expected functions array.' });
|
|
863
|
+
});
|
|
864
|
+
it('should return 400 when functions is not an array', async () => {
|
|
865
|
+
const invalidRequest = createMockRequest({
|
|
866
|
+
path: '/overrides',
|
|
867
|
+
method: 'PATCH',
|
|
868
|
+
bodyJSON: { functions: {} } // Should be array, not object
|
|
869
|
+
});
|
|
870
|
+
const response = await Service_1.toolsService.processRequest(invalidRequest, mockToolFunction);
|
|
871
|
+
expect(response.status).toBe(400);
|
|
872
|
+
expect(response.bodyJSON).toEqual({ error: 'Invalid request body. Expected functions array.' });
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
describe('DELETE /overrides endpoint', () => {
|
|
876
|
+
it('should delete overrides successfully', async () => {
|
|
877
|
+
const request = createMockRequest({
|
|
878
|
+
path: '/overrides',
|
|
879
|
+
method: 'DELETE'
|
|
880
|
+
});
|
|
881
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
882
|
+
expect(response.status).toBe(200);
|
|
883
|
+
expect(mockKvStore.delete).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides');
|
|
884
|
+
expect(response.bodyJSON).toEqual({ success: true });
|
|
885
|
+
});
|
|
886
|
+
it('should handle KV store errors during delete', async () => {
|
|
887
|
+
mockKvStore.delete.mockRejectedValue(new Error('KV store delete error'));
|
|
888
|
+
const request = createMockRequest({
|
|
889
|
+
path: '/overrides',
|
|
890
|
+
method: 'DELETE'
|
|
891
|
+
});
|
|
892
|
+
const response = await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
893
|
+
expect(response.status).toBe(500);
|
|
894
|
+
expect(app_sdk_1.logger.error).toHaveBeenCalledWith('Error deleting tool overrides:', expect.any(Error));
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
describe('Data format transformation', () => {
|
|
898
|
+
it('should transform API format (array) to storage format (map)', async () => {
|
|
899
|
+
const apiFormatData = {
|
|
900
|
+
functions: [
|
|
901
|
+
{
|
|
902
|
+
name: 'search_tool',
|
|
903
|
+
description: 'Search functionality',
|
|
904
|
+
parameters: [
|
|
905
|
+
{
|
|
906
|
+
name: 'query',
|
|
907
|
+
type: 'string',
|
|
908
|
+
description: 'Search query',
|
|
909
|
+
required: true
|
|
910
|
+
}
|
|
911
|
+
]
|
|
912
|
+
}
|
|
913
|
+
]
|
|
914
|
+
};
|
|
915
|
+
const request = createMockRequest({
|
|
916
|
+
path: '/overrides',
|
|
917
|
+
method: 'PATCH',
|
|
918
|
+
bodyJSON: apiFormatData
|
|
919
|
+
});
|
|
920
|
+
await Service_1.toolsService.processRequest(request, mockToolFunction);
|
|
921
|
+
// Verify that data was transformed to storage format (map instead of array)
|
|
922
|
+
const expectedStorageFormat = {
|
|
923
|
+
search_tool: {
|
|
924
|
+
name: 'search_tool',
|
|
925
|
+
description: 'Search functionality',
|
|
926
|
+
parameters: [
|
|
927
|
+
{
|
|
928
|
+
name: 'query',
|
|
929
|
+
type: 'string',
|
|
930
|
+
description: 'Search query',
|
|
931
|
+
required: true
|
|
932
|
+
}
|
|
933
|
+
]
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides', expectedStorageFormat);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
describe('getToolsWithOverrides method', () => {
|
|
940
|
+
it('should return tools with overrides applied', async () => {
|
|
941
|
+
const storedOverrides = {
|
|
942
|
+
search_tool: {
|
|
943
|
+
name: 'search_tool',
|
|
944
|
+
description: 'Enhanced search functionality'
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
mockKvStore.get.mockResolvedValue(storedOverrides);
|
|
948
|
+
const toolsWithOverrides = await Service_1.toolsService.getToolsWithOverrides('app-v1', 'TestFunction');
|
|
949
|
+
expect(toolsWithOverrides).toHaveLength(2);
|
|
950
|
+
const searchTool = toolsWithOverrides.find((t) => t.name === 'search_tool');
|
|
951
|
+
expect(searchTool?.description).toBe('Enhanced search functionality');
|
|
952
|
+
});
|
|
953
|
+
it('should return original tools when no overrides exist', async () => {
|
|
954
|
+
mockKvStore.get.mockResolvedValue(null);
|
|
955
|
+
const toolsWithOverrides = await Service_1.toolsService.getToolsWithOverrides('app-v1', 'TestFunction');
|
|
956
|
+
expect(toolsWithOverrides).toHaveLength(2);
|
|
957
|
+
const searchTool = toolsWithOverrides.find((t) => t.name === 'search_tool');
|
|
958
|
+
expect(searchTool?.description).toBe('Search for information');
|
|
959
|
+
});
|
|
960
|
+
});
|
|
425
961
|
});
|
|
426
962
|
});
|
|
427
963
|
//# sourceMappingURL=Service.test.js.map
|