@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.
Files changed (74) hide show
  1. package/README.md +169 -3
  2. package/dist/auth/AuthUtils.d.ts +12 -5
  3. package/dist/auth/AuthUtils.d.ts.map +1 -1
  4. package/dist/auth/AuthUtils.js +80 -25
  5. package/dist/auth/AuthUtils.js.map +1 -1
  6. package/dist/auth/AuthUtils.test.js +161 -117
  7. package/dist/auth/AuthUtils.test.js.map +1 -1
  8. package/dist/function/GlobalToolFunction.d.ts +5 -3
  9. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  10. package/dist/function/GlobalToolFunction.js +32 -8
  11. package/dist/function/GlobalToolFunction.js.map +1 -1
  12. package/dist/function/GlobalToolFunction.test.js +73 -12
  13. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  14. package/dist/function/ToolFunction.d.ts +11 -4
  15. package/dist/function/ToolFunction.d.ts.map +1 -1
  16. package/dist/function/ToolFunction.js +45 -9
  17. package/dist/function/ToolFunction.js.map +1 -1
  18. package/dist/function/ToolFunction.test.js +278 -11
  19. package/dist/function/ToolFunction.test.js.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/logging/ToolLogger.d.ts +42 -0
  25. package/dist/logging/ToolLogger.d.ts.map +1 -0
  26. package/dist/logging/ToolLogger.js +255 -0
  27. package/dist/logging/ToolLogger.js.map +1 -0
  28. package/dist/logging/ToolLogger.test.d.ts +2 -0
  29. package/dist/logging/ToolLogger.test.d.ts.map +1 -0
  30. package/dist/logging/ToolLogger.test.js +864 -0
  31. package/dist/logging/ToolLogger.test.js.map +1 -0
  32. package/dist/service/Service.d.ts +88 -2
  33. package/dist/service/Service.d.ts.map +1 -1
  34. package/dist/service/Service.js +228 -39
  35. package/dist/service/Service.js.map +1 -1
  36. package/dist/service/Service.test.js +558 -22
  37. package/dist/service/Service.test.js.map +1 -1
  38. package/dist/types/Models.d.ts +7 -1
  39. package/dist/types/Models.d.ts.map +1 -1
  40. package/dist/types/Models.js +5 -1
  41. package/dist/types/Models.js.map +1 -1
  42. package/dist/types/ToolError.d.ts +72 -0
  43. package/dist/types/ToolError.d.ts.map +1 -0
  44. package/dist/types/ToolError.js +107 -0
  45. package/dist/types/ToolError.js.map +1 -0
  46. package/dist/types/ToolError.test.d.ts +2 -0
  47. package/dist/types/ToolError.test.d.ts.map +1 -0
  48. package/dist/types/ToolError.test.js +185 -0
  49. package/dist/types/ToolError.test.js.map +1 -0
  50. package/dist/validation/ParameterValidator.d.ts +31 -0
  51. package/dist/validation/ParameterValidator.d.ts.map +1 -0
  52. package/dist/validation/ParameterValidator.js +129 -0
  53. package/dist/validation/ParameterValidator.js.map +1 -0
  54. package/dist/validation/ParameterValidator.test.d.ts +2 -0
  55. package/dist/validation/ParameterValidator.test.d.ts.map +1 -0
  56. package/dist/validation/ParameterValidator.test.js +323 -0
  57. package/dist/validation/ParameterValidator.test.js.map +1 -0
  58. package/package.json +3 -3
  59. package/src/auth/AuthUtils.test.ts +176 -157
  60. package/src/auth/AuthUtils.ts +96 -33
  61. package/src/function/GlobalToolFunction.test.ts +78 -14
  62. package/src/function/GlobalToolFunction.ts +46 -11
  63. package/src/function/ToolFunction.test.ts +298 -13
  64. package/src/function/ToolFunction.ts +61 -13
  65. package/src/index.ts +2 -1
  66. package/src/logging/ToolLogger.test.ts +1020 -0
  67. package/src/logging/ToolLogger.ts +292 -0
  68. package/src/service/Service.test.ts +712 -28
  69. package/src/service/Service.ts +288 -38
  70. package/src/types/Models.ts +8 -1
  71. package/src/types/ToolError.test.ts +222 -0
  72. package/src/types/ToolError.ts +125 -0
  73. package/src/validation/ParameterValidator.test.ts +371 -0
  74. 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
- logger: {
10
- error: jest.fn()
11
- },
12
- Function: class {
13
- request;
14
- constructor(request) {
15
- this.request = request;
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
- Response: jest.fn().mockImplementation((status, data) => ({
19
- status,
20
- data,
21
- bodyJSON: data,
22
- bodyAsU8Array: new Uint8Array()
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 an error', async () => {
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 an error', async () => {
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
- Service_1.toolsService.registerTool(mockTool.name, mockTool.description, mockTool.handler, mockTool.parameters, mockTool.endpoint);
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(mockTool.handler).toHaveBeenCalledWith(mockToolFunction, null, undefined);
506
+ expect(toolWithoutRequiredParams.handler).toHaveBeenCalledWith(mockToolFunction, null, undefined);
364
507
  });
365
508
  it('should handle request with undefined bodyJSON', async () => {
366
- Service_1.toolsService.registerTool(mockTool.name, mockTool.description, mockTool.handler, mockTool.parameters, mockTool.endpoint);
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(mockTool.handler).toHaveBeenCalledWith(mockToolFunction, undefined, undefined);
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