@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,23 +1,80 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
1
2
|
import { toolsService, Tool, Interaction } from './Service';
|
|
2
3
|
import { Parameter, ParameterType, AuthRequirement, OptiIdAuthDataCredentials, OptiIdAuthData } from '../types/Models';
|
|
4
|
+
import { ToolError } from '../types/ToolError';
|
|
3
5
|
import { ToolFunction } from '../function/ToolFunction';
|
|
4
6
|
import { logger } from '@zaiusinc/app-sdk';
|
|
5
7
|
|
|
6
8
|
// Mock the logger and other app-sdk exports
|
|
7
|
-
jest.mock('@zaiusinc/app-sdk', () =>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
jest.mock('@zaiusinc/app-sdk', () => {
|
|
10
|
+
const mockKvStoreInstance = {
|
|
11
|
+
get: jest.fn(),
|
|
12
|
+
put: jest.fn(),
|
|
13
|
+
patch: jest.fn(),
|
|
14
|
+
delete: jest.fn()
|
|
15
|
+
};
|
|
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
|
+
public constructor(public request: any) {}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
...mockApp,
|
|
35
|
+
default: mockApp,
|
|
36
|
+
App: mockApp,
|
|
37
|
+
getAppContext: jest.fn().mockReturnValue({
|
|
38
|
+
manifest: { meta: { version: 'app-v1' } }
|
|
39
|
+
}),
|
|
40
|
+
logger: {
|
|
41
|
+
error: jest.fn(),
|
|
42
|
+
info: jest.fn(),
|
|
43
|
+
warn: jest.fn(),
|
|
44
|
+
debug: jest.fn()
|
|
45
|
+
},
|
|
46
|
+
Headers: class {
|
|
47
|
+
public constructor(headers: string[][] = []) {
|
|
48
|
+
this.headers = {};
|
|
49
|
+
headers.forEach(([name, value]) => {
|
|
50
|
+
this.headers[name.toLowerCase()] = value;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
public headers: { [key: string]: string };
|
|
54
|
+
public set(name: string, value: string) {
|
|
55
|
+
this.headers[name.toLowerCase()] = value;
|
|
56
|
+
}
|
|
57
|
+
public get(name: string) {
|
|
58
|
+
return this.headers[name.toLowerCase()];
|
|
59
|
+
}
|
|
60
|
+
public has(name: string) {
|
|
61
|
+
return name.toLowerCase() in this.headers;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
Response: jest.fn().mockImplementation((status, data, headers) => ({
|
|
65
|
+
status,
|
|
66
|
+
data,
|
|
67
|
+
bodyJSON: data,
|
|
68
|
+
bodyAsU8Array: new Uint8Array(),
|
|
69
|
+
headers: headers || { get: jest.fn(), has: jest.fn(), set: jest.fn() }
|
|
70
|
+
}))
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Get the mocked kvStore for use in tests
|
|
75
|
+
const { storage } = jest.requireMock('@zaiusinc/app-sdk');
|
|
76
|
+
const mockKvStore = storage.kvStore;
|
|
77
|
+
|
|
21
78
|
|
|
22
79
|
describe('ToolsService', () => {
|
|
23
80
|
let mockTool: Tool<unknown>;
|
|
@@ -351,7 +408,7 @@ describe('ToolsService', () => {
|
|
|
351
408
|
);
|
|
352
409
|
});
|
|
353
410
|
|
|
354
|
-
it('should return 500 error when tool handler throws
|
|
411
|
+
it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
|
|
355
412
|
const errorMessage = 'Tool execution failed';
|
|
356
413
|
jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
357
414
|
|
|
@@ -359,6 +416,13 @@ describe('ToolsService', () => {
|
|
|
359
416
|
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
360
417
|
|
|
361
418
|
expect(response.status).toBe(500);
|
|
419
|
+
expect(response.bodyJSON).toEqual({
|
|
420
|
+
title: 'Internal Server Error',
|
|
421
|
+
status: 500,
|
|
422
|
+
detail: errorMessage,
|
|
423
|
+
instance: mockTool.endpoint
|
|
424
|
+
});
|
|
425
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
362
426
|
expect(logger.error).toHaveBeenCalledWith(
|
|
363
427
|
`Error in function ${mockTool.name}:`,
|
|
364
428
|
expect.any(Error)
|
|
@@ -372,6 +436,67 @@ describe('ToolsService', () => {
|
|
|
372
436
|
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
373
437
|
|
|
374
438
|
expect(response.status).toBe(500);
|
|
439
|
+
expect(response.bodyJSON).toEqual({
|
|
440
|
+
title: 'Internal Server Error',
|
|
441
|
+
status: 500,
|
|
442
|
+
detail: 'An unexpected error occurred',
|
|
443
|
+
instance: mockTool.endpoint
|
|
444
|
+
});
|
|
445
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should return custom status code when tool handler throws ToolError', async () => {
|
|
449
|
+
const toolError = new ToolError('Resource not found', 404, 'The requested task does not exist');
|
|
450
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
451
|
+
|
|
452
|
+
const mockRequest = createMockRequest();
|
|
453
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
454
|
+
|
|
455
|
+
expect(response.status).toBe(404);
|
|
456
|
+
expect(response.bodyJSON).toEqual({
|
|
457
|
+
title: 'Resource not found',
|
|
458
|
+
status: 404,
|
|
459
|
+
detail: 'The requested task does not exist',
|
|
460
|
+
instance: mockTool.endpoint
|
|
461
|
+
});
|
|
462
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
463
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
464
|
+
`Error in function ${mockTool.name}:`,
|
|
465
|
+
expect.any(ToolError)
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should return ToolError without detail field when detail is not provided', async () => {
|
|
470
|
+
const toolError = new ToolError('Bad request', 400);
|
|
471
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
472
|
+
|
|
473
|
+
const mockRequest = createMockRequest();
|
|
474
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
475
|
+
|
|
476
|
+
expect(response.status).toBe(400);
|
|
477
|
+
expect(response.bodyJSON).toEqual({
|
|
478
|
+
title: 'Bad request',
|
|
479
|
+
status: 400,
|
|
480
|
+
instance: mockTool.endpoint
|
|
481
|
+
});
|
|
482
|
+
expect(response.bodyJSON).not.toHaveProperty('detail');
|
|
483
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should default to 500 when ToolError is created without status', async () => {
|
|
487
|
+
const toolError = new ToolError('Database error');
|
|
488
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
489
|
+
|
|
490
|
+
const mockRequest = createMockRequest();
|
|
491
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
492
|
+
|
|
493
|
+
expect(response.status).toBe(500);
|
|
494
|
+
expect(response.bodyJSON).toEqual({
|
|
495
|
+
title: 'Database error',
|
|
496
|
+
status: 500,
|
|
497
|
+
instance: mockTool.endpoint
|
|
498
|
+
});
|
|
499
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
375
500
|
});
|
|
376
501
|
});
|
|
377
502
|
|
|
@@ -469,7 +594,7 @@ describe('ToolsService', () => {
|
|
|
469
594
|
);
|
|
470
595
|
});
|
|
471
596
|
|
|
472
|
-
it('should return 500 error when interaction handler throws
|
|
597
|
+
it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
|
|
473
598
|
const errorMessage = 'Interaction execution failed';
|
|
474
599
|
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
475
600
|
|
|
@@ -481,11 +606,43 @@ describe('ToolsService', () => {
|
|
|
481
606
|
const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
482
607
|
|
|
483
608
|
expect(response.status).toBe(500);
|
|
609
|
+
expect(response.bodyJSON).toEqual({
|
|
610
|
+
title: 'Internal Server Error',
|
|
611
|
+
status: 500,
|
|
612
|
+
detail: errorMessage,
|
|
613
|
+
instance: mockInteraction.endpoint
|
|
614
|
+
});
|
|
615
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
484
616
|
expect(logger.error).toHaveBeenCalledWith(
|
|
485
617
|
`Error in function ${mockInteraction.name}:`,
|
|
486
618
|
expect.any(Error)
|
|
487
619
|
);
|
|
488
620
|
});
|
|
621
|
+
|
|
622
|
+
it('should return custom status code when interaction handler throws ToolError', async () => {
|
|
623
|
+
const toolError = new ToolError('Webhook validation failed', 400, 'Invalid signature');
|
|
624
|
+
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(toolError);
|
|
625
|
+
|
|
626
|
+
const interactionRequest = createMockRequest({
|
|
627
|
+
path: '/test-interaction',
|
|
628
|
+
bodyJSON: { data: { param1: 'test-value' } }
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
632
|
+
|
|
633
|
+
expect(response.status).toBe(400);
|
|
634
|
+
expect(response.bodyJSON).toEqual({
|
|
635
|
+
title: 'Webhook validation failed',
|
|
636
|
+
status: 400,
|
|
637
|
+
detail: 'Invalid signature',
|
|
638
|
+
instance: mockInteraction.endpoint
|
|
639
|
+
});
|
|
640
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
641
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
642
|
+
`Error in function ${mockInteraction.name}:`,
|
|
643
|
+
expect.any(ToolError)
|
|
644
|
+
);
|
|
645
|
+
});
|
|
489
646
|
});
|
|
490
647
|
|
|
491
648
|
describe('error cases', () => {
|
|
@@ -522,15 +679,25 @@ describe('ToolsService', () => {
|
|
|
522
679
|
|
|
523
680
|
describe('edge cases', () => {
|
|
524
681
|
it('should handle request with null bodyJSON', async () => {
|
|
682
|
+
// Create a tool without required parameters
|
|
683
|
+
const toolWithoutRequiredParams = {
|
|
684
|
+
name: 'no_required_params_tool',
|
|
685
|
+
description: 'Tool without required parameters',
|
|
686
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
687
|
+
parameters: [], // No parameters defined
|
|
688
|
+
endpoint: '/no-required-params-tool'
|
|
689
|
+
};
|
|
690
|
+
|
|
525
691
|
toolsService.registerTool(
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
692
|
+
toolWithoutRequiredParams.name,
|
|
693
|
+
toolWithoutRequiredParams.description,
|
|
694
|
+
toolWithoutRequiredParams.handler,
|
|
695
|
+
toolWithoutRequiredParams.parameters,
|
|
696
|
+
toolWithoutRequiredParams.endpoint
|
|
531
697
|
);
|
|
532
698
|
|
|
533
699
|
const requestWithNullBody = createMockRequest({
|
|
700
|
+
path: '/no-required-params-tool',
|
|
534
701
|
bodyJSON: null,
|
|
535
702
|
body: null
|
|
536
703
|
});
|
|
@@ -538,19 +705,29 @@ describe('ToolsService', () => {
|
|
|
538
705
|
const response = await toolsService.processRequest(requestWithNullBody, mockToolFunction);
|
|
539
706
|
|
|
540
707
|
expect(response.status).toBe(200);
|
|
541
|
-
expect(
|
|
708
|
+
expect(toolWithoutRequiredParams.handler).toHaveBeenCalledWith(mockToolFunction, null, undefined);
|
|
542
709
|
});
|
|
543
710
|
|
|
544
711
|
it('should handle request with undefined bodyJSON', async () => {
|
|
712
|
+
// Create a tool without required parameters
|
|
713
|
+
const toolWithoutRequiredParams = {
|
|
714
|
+
name: 'no_required_params_tool_2',
|
|
715
|
+
description: 'Tool without required parameters',
|
|
716
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
717
|
+
parameters: [], // No parameters defined
|
|
718
|
+
endpoint: '/no-required-params-tool-2'
|
|
719
|
+
};
|
|
720
|
+
|
|
545
721
|
toolsService.registerTool(
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
722
|
+
toolWithoutRequiredParams.name,
|
|
723
|
+
toolWithoutRequiredParams.description,
|
|
724
|
+
toolWithoutRequiredParams.handler,
|
|
725
|
+
toolWithoutRequiredParams.parameters,
|
|
726
|
+
toolWithoutRequiredParams.endpoint
|
|
551
727
|
);
|
|
552
728
|
|
|
553
729
|
const requestWithUndefinedBody = createMockRequest({
|
|
730
|
+
path: '/no-required-params-tool-2',
|
|
554
731
|
bodyJSON: undefined,
|
|
555
732
|
body: undefined
|
|
556
733
|
});
|
|
@@ -558,7 +735,7 @@ describe('ToolsService', () => {
|
|
|
558
735
|
const response = await toolsService.processRequest(requestWithUndefinedBody, mockToolFunction);
|
|
559
736
|
|
|
560
737
|
expect(response.status).toBe(200);
|
|
561
|
-
expect(
|
|
738
|
+
expect(toolWithoutRequiredParams.handler).toHaveBeenCalledWith(mockToolFunction, undefined, undefined);
|
|
562
739
|
});
|
|
563
740
|
|
|
564
741
|
it('should extract auth data from bodyJSON when body exists', async () => {
|
|
@@ -657,5 +834,512 @@ describe('ToolsService', () => {
|
|
|
657
834
|
);
|
|
658
835
|
});
|
|
659
836
|
});
|
|
837
|
+
|
|
838
|
+
describe('parameter validation', () => {
|
|
839
|
+
beforeEach(() => {
|
|
840
|
+
jest.clearAllMocks();
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('should validate parameters and return 400 for invalid types', async () => {
|
|
844
|
+
// Register a tool with specific parameter types
|
|
845
|
+
const toolWithTypedParams = {
|
|
846
|
+
name: 'typed_tool',
|
|
847
|
+
description: 'Tool with typed parameters',
|
|
848
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
849
|
+
parameters: [
|
|
850
|
+
new Parameter('name', ParameterType.String, 'User name', true),
|
|
851
|
+
new Parameter('age', ParameterType.Integer, 'User age', true),
|
|
852
|
+
new Parameter('active', ParameterType.Boolean, 'Is active', false)
|
|
853
|
+
],
|
|
854
|
+
endpoint: '/typed-tool'
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
toolsService.registerTool(
|
|
858
|
+
toolWithTypedParams.name,
|
|
859
|
+
toolWithTypedParams.description,
|
|
860
|
+
toolWithTypedParams.handler,
|
|
861
|
+
toolWithTypedParams.parameters,
|
|
862
|
+
toolWithTypedParams.endpoint
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
// Send invalid parameter types
|
|
866
|
+
const invalidRequest = createMockRequest({
|
|
867
|
+
path: '/typed-tool',
|
|
868
|
+
bodyJSON: {
|
|
869
|
+
parameters: {
|
|
870
|
+
name: 123, // should be string
|
|
871
|
+
age: '25', // should be integer
|
|
872
|
+
active: 'true' // should be boolean
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const response = await toolsService.processRequest(invalidRequest, mockToolFunction);
|
|
878
|
+
|
|
879
|
+
expect(response.status).toBe(400);
|
|
880
|
+
|
|
881
|
+
// Expect RFC 9457 Problem Details format
|
|
882
|
+
expect(response.bodyJSON).toHaveProperty('title', 'One or more validation errors occurred.');
|
|
883
|
+
expect(response.bodyJSON).toHaveProperty('status', 400);
|
|
884
|
+
expect(response.bodyJSON).toHaveProperty('detail', 'See \'errors\' field for details.');
|
|
885
|
+
expect(response.bodyJSON).toHaveProperty('instance', '/typed-tool');
|
|
886
|
+
expect(response.bodyJSON).toHaveProperty('errors');
|
|
887
|
+
expect(response.bodyJSON.errors).toHaveLength(3);
|
|
888
|
+
|
|
889
|
+
// Check error structure - field and message
|
|
890
|
+
const errors = response.bodyJSON.errors;
|
|
891
|
+
expect(errors[0]).toHaveProperty('field', 'name');
|
|
892
|
+
expect(errors[0]).toHaveProperty('message', "Parameter 'name' must be a string, but received number");
|
|
893
|
+
|
|
894
|
+
// Check that the content type is set to application/problem+json for RFC 9457 compliance
|
|
895
|
+
expect(response.headers).toBeDefined();
|
|
896
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
897
|
+
|
|
898
|
+
// Verify the handler was not called
|
|
899
|
+
expect(toolWithTypedParams.handler).not.toHaveBeenCalled();
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('should skip validation for tools with no parameter definitions', async () => {
|
|
903
|
+
const toolWithoutParams = {
|
|
904
|
+
name: 'no_params_tool',
|
|
905
|
+
description: 'Tool without parameters',
|
|
906
|
+
handler: jest.fn().mockResolvedValue({ result: 'success' }),
|
|
907
|
+
parameters: [], // No parameters defined
|
|
908
|
+
endpoint: '/no-params-tool'
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
toolsService.registerTool(
|
|
912
|
+
toolWithoutParams.name,
|
|
913
|
+
toolWithoutParams.description,
|
|
914
|
+
toolWithoutParams.handler,
|
|
915
|
+
toolWithoutParams.parameters,
|
|
916
|
+
toolWithoutParams.endpoint
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
// Send request with any data (should be ignored)
|
|
920
|
+
const request = createMockRequest({
|
|
921
|
+
path: '/no-params-tool',
|
|
922
|
+
bodyJSON: {
|
|
923
|
+
parameters: {
|
|
924
|
+
unexpected: 'value'
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
930
|
+
|
|
931
|
+
expect(response.status).toBe(200);
|
|
932
|
+
expect(toolWithoutParams.handler).toHaveBeenCalledWith(
|
|
933
|
+
mockToolFunction,
|
|
934
|
+
{ unexpected: 'value' },
|
|
935
|
+
undefined
|
|
936
|
+
);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
describe('Override functionality', () => {
|
|
942
|
+
beforeEach(() => {
|
|
943
|
+
// Reset KV store mocks
|
|
944
|
+
mockKvStore.get.mockReset();
|
|
945
|
+
mockKvStore.put.mockReset();
|
|
946
|
+
mockKvStore.patch.mockReset();
|
|
947
|
+
mockKvStore.delete.mockReset();
|
|
948
|
+
|
|
949
|
+
// Register some test tools for override testing
|
|
950
|
+
toolsService.registerTool(
|
|
951
|
+
'search_tool',
|
|
952
|
+
'Search for information',
|
|
953
|
+
jest.fn().mockResolvedValue({ result: 'search success' }),
|
|
954
|
+
[new Parameter('query', ParameterType.String, 'Search query', true)],
|
|
955
|
+
'/search'
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
toolsService.registerTool(
|
|
959
|
+
'calculator',
|
|
960
|
+
'Perform calculations',
|
|
961
|
+
jest.fn().mockResolvedValue({ result: 'calculation success' }),
|
|
962
|
+
[
|
|
963
|
+
new Parameter('operation', ParameterType.String, 'Math operation', true),
|
|
964
|
+
new Parameter('numbers', ParameterType.String, 'Numbers to calculate', true)
|
|
965
|
+
],
|
|
966
|
+
'/calculate'
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
// Mock ToolFunction constructor name for getAppVersionId and getFunctionName
|
|
970
|
+
Object.defineProperty(mockToolFunction.constructor, 'name', {
|
|
971
|
+
value: 'TestFunction',
|
|
972
|
+
configurable: true
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
describe('Discovery endpoint with overrides', () => {
|
|
977
|
+
it('should return original functions when no overrides exist', async () => {
|
|
978
|
+
// Mock KV store to return no overrides
|
|
979
|
+
mockKvStore.get.mockResolvedValue(null);
|
|
980
|
+
|
|
981
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
982
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
983
|
+
|
|
984
|
+
expect(response.status).toBe(200);
|
|
985
|
+
expect(mockKvStore.get).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides');
|
|
986
|
+
|
|
987
|
+
const responseData = response.bodyJSON as { functions: any[] };
|
|
988
|
+
expect(responseData.functions).toHaveLength(2);
|
|
989
|
+
|
|
990
|
+
const searchTool = responseData.functions.find((f) => f.name === 'search_tool');
|
|
991
|
+
expect(searchTool.description).toBe('Search for information');
|
|
992
|
+
|
|
993
|
+
const calculator = responseData.functions.find((f) => f.name === 'calculator');
|
|
994
|
+
expect(calculator.description).toBe('Perform calculations');
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it('should apply overrides when they exist', async () => {
|
|
998
|
+
// Mock KV store to return overrides in the new map format
|
|
999
|
+
const storedOverrides = {
|
|
1000
|
+
search_tool: {
|
|
1001
|
+
name: 'search_tool',
|
|
1002
|
+
description: 'Enhanced search with AI capabilities',
|
|
1003
|
+
parameters: [
|
|
1004
|
+
{
|
|
1005
|
+
name: 'query',
|
|
1006
|
+
description: 'AI-powered search query'
|
|
1007
|
+
}
|
|
1008
|
+
]
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
mockKvStore.get.mockResolvedValue(storedOverrides);
|
|
1013
|
+
|
|
1014
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
1015
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1016
|
+
|
|
1017
|
+
expect(response.status).toBe(200);
|
|
1018
|
+
|
|
1019
|
+
const responseData = response.bodyJSON as { functions: any[] };
|
|
1020
|
+
expect(responseData.functions).toHaveLength(2);
|
|
1021
|
+
|
|
1022
|
+
// Check that search_tool has overridden description and parameters
|
|
1023
|
+
const searchTool = responseData.functions.find((f) => f.name === 'search_tool');
|
|
1024
|
+
expect(searchTool.description).toBe('Enhanced search with AI capabilities');
|
|
1025
|
+
expect(searchTool.parameters).toHaveLength(1); // Only the original 'query' parameter
|
|
1026
|
+
expect(searchTool.parameters[0].description).toBe('AI-powered search query');
|
|
1027
|
+
|
|
1028
|
+
// Check that calculator remains unchanged
|
|
1029
|
+
const calculator = responseData.functions.find((f) => f.name === 'calculator');
|
|
1030
|
+
expect(calculator.description).toBe('Perform calculations');
|
|
1031
|
+
expect(calculator.parameters).toHaveLength(2);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('should handle KV store errors gracefully', async () => {
|
|
1035
|
+
// Mock KV store to throw an error
|
|
1036
|
+
mockKvStore.get.mockRejectedValue(new Error('KV store unavailable'));
|
|
1037
|
+
|
|
1038
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
1039
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1040
|
+
|
|
1041
|
+
expect(response.status).toBe(200);
|
|
1042
|
+
|
|
1043
|
+
// Should return original functions despite the error
|
|
1044
|
+
const responseData = response.bodyJSON as { functions: any[] };
|
|
1045
|
+
expect(responseData.functions).toHaveLength(2);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('should handle KV store errors gracefully when getting overrides fails', async () => {
|
|
1049
|
+
// Mock KV store to throw an error when getting overrides
|
|
1050
|
+
mockKvStore.get.mockRejectedValue(new Error('KV store connection failed'));
|
|
1051
|
+
|
|
1052
|
+
const request = createMockRequest({ path: '/discovery', method: 'GET' });
|
|
1053
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1054
|
+
|
|
1055
|
+
expect(response.status).toBe(200);
|
|
1056
|
+
|
|
1057
|
+
// Should return original functions despite the error
|
|
1058
|
+
const responseData = response.bodyJSON as { functions: any[] };
|
|
1059
|
+
expect(responseData.functions).toHaveLength(2);
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
describe('PATCH /overrides endpoint', () => {
|
|
1064
|
+
it('should save new overrides successfully', async () => {
|
|
1065
|
+
const overrideData = {
|
|
1066
|
+
functions: [
|
|
1067
|
+
{
|
|
1068
|
+
name: 'search_tool',
|
|
1069
|
+
description: 'Enhanced search functionality',
|
|
1070
|
+
parameters: [
|
|
1071
|
+
{
|
|
1072
|
+
name: 'query',
|
|
1073
|
+
type: 'string',
|
|
1074
|
+
description: 'Search query',
|
|
1075
|
+
required: true
|
|
1076
|
+
}
|
|
1077
|
+
]
|
|
1078
|
+
}
|
|
1079
|
+
]
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
const request = createMockRequest({
|
|
1083
|
+
path: '/overrides',
|
|
1084
|
+
method: 'PATCH',
|
|
1085
|
+
bodyJSON: overrideData,
|
|
1086
|
+
body: JSON.stringify(overrideData)
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1090
|
+
|
|
1091
|
+
expect(response.status).toBe(200);
|
|
1092
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith(
|
|
1093
|
+
'app-v1:TestFunction:opal-tools-overrides',
|
|
1094
|
+
{
|
|
1095
|
+
search_tool: {
|
|
1096
|
+
name: 'search_tool',
|
|
1097
|
+
description: 'Enhanced search functionality',
|
|
1098
|
+
parameters: [
|
|
1099
|
+
{
|
|
1100
|
+
name: 'query',
|
|
1101
|
+
type: 'string',
|
|
1102
|
+
description: 'Search query',
|
|
1103
|
+
required: true
|
|
1104
|
+
}
|
|
1105
|
+
]
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it('should merge with existing overrides', async () => {
|
|
1112
|
+
const newOverrideData = {
|
|
1113
|
+
functions: [
|
|
1114
|
+
{
|
|
1115
|
+
name: 'new_tool',
|
|
1116
|
+
description: 'New tool',
|
|
1117
|
+
parameters: []
|
|
1118
|
+
}
|
|
1119
|
+
]
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
const request = createMockRequest({
|
|
1123
|
+
path: '/overrides',
|
|
1124
|
+
method: 'PATCH',
|
|
1125
|
+
bodyJSON: newOverrideData,
|
|
1126
|
+
body: JSON.stringify(newOverrideData)
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1130
|
+
|
|
1131
|
+
expect(response.status).toBe(200);
|
|
1132
|
+
|
|
1133
|
+
// Verify that the new tool is saved using patch (which handles merging automatically)
|
|
1134
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith(
|
|
1135
|
+
'app-v1:TestFunction:opal-tools-overrides',
|
|
1136
|
+
{
|
|
1137
|
+
new_tool: {
|
|
1138
|
+
name: 'new_tool',
|
|
1139
|
+
description: 'New tool',
|
|
1140
|
+
parameters: []
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it('should update existing tool when saving override with same name', async () => {
|
|
1147
|
+
const updatedOverrideData = {
|
|
1148
|
+
functions: [
|
|
1149
|
+
{
|
|
1150
|
+
name: 'search_tool',
|
|
1151
|
+
description: 'Updated description',
|
|
1152
|
+
parameters: [
|
|
1153
|
+
{
|
|
1154
|
+
name: 'query',
|
|
1155
|
+
type: 'string',
|
|
1156
|
+
description: 'Search query',
|
|
1157
|
+
required: true
|
|
1158
|
+
}
|
|
1159
|
+
]
|
|
1160
|
+
}
|
|
1161
|
+
]
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const request = createMockRequest({
|
|
1165
|
+
path: '/overrides',
|
|
1166
|
+
method: 'PATCH',
|
|
1167
|
+
bodyJSON: updatedOverrideData,
|
|
1168
|
+
body: JSON.stringify(updatedOverrideData)
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1172
|
+
|
|
1173
|
+
expect(response.status).toBe(200);
|
|
1174
|
+
|
|
1175
|
+
// Verify that the existing tool was updated using patch
|
|
1176
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith(
|
|
1177
|
+
'app-v1:TestFunction:opal-tools-overrides',
|
|
1178
|
+
{
|
|
1179
|
+
search_tool: {
|
|
1180
|
+
name: 'search_tool',
|
|
1181
|
+
description: 'Updated description',
|
|
1182
|
+
parameters: [
|
|
1183
|
+
{
|
|
1184
|
+
name: 'query',
|
|
1185
|
+
type: 'string',
|
|
1186
|
+
description: 'Search query',
|
|
1187
|
+
required: true
|
|
1188
|
+
}
|
|
1189
|
+
]
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it('should handle KV store errors during save', async () => {
|
|
1196
|
+
mockKvStore.patch.mockRejectedValue(new Error('KV store write error'));
|
|
1197
|
+
|
|
1198
|
+
const request = createMockRequest({
|
|
1199
|
+
path: '/overrides',
|
|
1200
|
+
method: 'PATCH',
|
|
1201
|
+
bodyJSON: { functions: [] }
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1205
|
+
|
|
1206
|
+
expect(response.status).toBe(500);
|
|
1207
|
+
expect(logger.error).toHaveBeenCalledWith('Error saving tool overrides:', expect.any(Error));
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('should return 400 for invalid request body format', async () => {
|
|
1211
|
+
const invalidRequest = createMockRequest({
|
|
1212
|
+
path: '/overrides',
|
|
1213
|
+
method: 'PATCH',
|
|
1214
|
+
bodyJSON: { tools: [] } // Wrong property name, should be 'functions'
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
const response = await toolsService.processRequest(invalidRequest, mockToolFunction);
|
|
1218
|
+
|
|
1219
|
+
expect(response.status).toBe(400);
|
|
1220
|
+
expect(response.bodyJSON).toEqual({ error: 'Invalid request body. Expected functions array.' });
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it('should return 400 when functions is not an array', async () => {
|
|
1224
|
+
const invalidRequest = createMockRequest({
|
|
1225
|
+
path: '/overrides',
|
|
1226
|
+
method: 'PATCH',
|
|
1227
|
+
bodyJSON: { functions: {} } // Should be array, not object
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
const response = await toolsService.processRequest(invalidRequest, mockToolFunction);
|
|
1231
|
+
|
|
1232
|
+
expect(response.status).toBe(400);
|
|
1233
|
+
expect(response.bodyJSON).toEqual({ error: 'Invalid request body. Expected functions array.' });
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
describe('DELETE /overrides endpoint', () => {
|
|
1238
|
+
it('should delete overrides successfully', async () => {
|
|
1239
|
+
const request = createMockRequest({
|
|
1240
|
+
path: '/overrides',
|
|
1241
|
+
method: 'DELETE'
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1245
|
+
|
|
1246
|
+
expect(response.status).toBe(200);
|
|
1247
|
+
expect(mockKvStore.delete).toHaveBeenCalledWith('app-v1:TestFunction:opal-tools-overrides');
|
|
1248
|
+
expect(response.bodyJSON).toEqual({ success: true });
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
it('should handle KV store errors during delete', async () => {
|
|
1252
|
+
mockKvStore.delete.mockRejectedValue(new Error('KV store delete error'));
|
|
1253
|
+
|
|
1254
|
+
const request = createMockRequest({
|
|
1255
|
+
path: '/overrides',
|
|
1256
|
+
method: 'DELETE'
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
const response = await toolsService.processRequest(request, mockToolFunction);
|
|
1260
|
+
|
|
1261
|
+
expect(response.status).toBe(500);
|
|
1262
|
+
expect(logger.error).toHaveBeenCalledWith('Error deleting tool overrides:', expect.any(Error));
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
describe('Data format transformation', () => {
|
|
1267
|
+
it('should transform API format (array) to storage format (map)', async () => {
|
|
1268
|
+
const apiFormatData = {
|
|
1269
|
+
functions: [
|
|
1270
|
+
{
|
|
1271
|
+
name: 'search_tool',
|
|
1272
|
+
description: 'Search functionality',
|
|
1273
|
+
parameters: [
|
|
1274
|
+
{
|
|
1275
|
+
name: 'query',
|
|
1276
|
+
type: 'string',
|
|
1277
|
+
description: 'Search query',
|
|
1278
|
+
required: true
|
|
1279
|
+
}
|
|
1280
|
+
]
|
|
1281
|
+
}
|
|
1282
|
+
]
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
const request = createMockRequest({
|
|
1286
|
+
path: '/overrides',
|
|
1287
|
+
method: 'PATCH',
|
|
1288
|
+
bodyJSON: apiFormatData
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
await toolsService.processRequest(request, mockToolFunction);
|
|
1292
|
+
|
|
1293
|
+
// Verify that data was transformed to storage format (map instead of array)
|
|
1294
|
+
const expectedStorageFormat = {
|
|
1295
|
+
search_tool: {
|
|
1296
|
+
name: 'search_tool',
|
|
1297
|
+
description: 'Search functionality',
|
|
1298
|
+
parameters: [
|
|
1299
|
+
{
|
|
1300
|
+
name: 'query',
|
|
1301
|
+
type: 'string',
|
|
1302
|
+
description: 'Search query',
|
|
1303
|
+
required: true
|
|
1304
|
+
}
|
|
1305
|
+
]
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
expect(mockKvStore.patch).toHaveBeenCalledWith(
|
|
1310
|
+
'app-v1:TestFunction:opal-tools-overrides',
|
|
1311
|
+
expectedStorageFormat
|
|
1312
|
+
);
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
describe('getToolsWithOverrides method', () => {
|
|
1317
|
+
it('should return tools with overrides applied', async () => {
|
|
1318
|
+
const storedOverrides = {
|
|
1319
|
+
search_tool: {
|
|
1320
|
+
name: 'search_tool',
|
|
1321
|
+
description: 'Enhanced search functionality'
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
mockKvStore.get.mockResolvedValue(storedOverrides);
|
|
1326
|
+
|
|
1327
|
+
const toolsWithOverrides = await toolsService.getToolsWithOverrides('app-v1', 'TestFunction');
|
|
1328
|
+
|
|
1329
|
+
expect(toolsWithOverrides).toHaveLength(2);
|
|
1330
|
+
const searchTool = toolsWithOverrides.find((t) => t.name === 'search_tool');
|
|
1331
|
+
expect(searchTool?.description).toBe('Enhanced search functionality');
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('should return original tools when no overrides exist', async () => {
|
|
1335
|
+
mockKvStore.get.mockResolvedValue(null);
|
|
1336
|
+
|
|
1337
|
+
const toolsWithOverrides = await toolsService.getToolsWithOverrides('app-v1', 'TestFunction');
|
|
1338
|
+
|
|
1339
|
+
expect(toolsWithOverrides).toHaveLength(2);
|
|
1340
|
+
const searchTool = toolsWithOverrides.find((t) => t.name === 'search_tool');
|
|
1341
|
+
expect(searchTool?.description).toBe('Search for information');
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
660
1344
|
});
|
|
661
1345
|
});
|