@optimizely-opal/opal-tool-ocp-sdk 1.0.0-OCP-1442.5 → 1.0.0-OCP-1449.1

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 (64) hide show
  1. package/README.md +114 -72
  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 +1 -1
  9. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  10. package/dist/function/GlobalToolFunction.js +17 -4
  11. package/dist/function/GlobalToolFunction.js.map +1 -1
  12. package/dist/function/GlobalToolFunction.test.js +54 -8
  13. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  14. package/dist/function/ToolFunction.d.ts +1 -1
  15. package/dist/function/ToolFunction.d.ts.map +1 -1
  16. package/dist/function/ToolFunction.js +24 -4
  17. package/dist/function/ToolFunction.js.map +1 -1
  18. package/dist/function/ToolFunction.test.js +260 -8
  19. package/dist/function/ToolFunction.test.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/logging/ToolLogger.d.ts.map +1 -1
  25. package/dist/logging/ToolLogger.js +13 -12
  26. package/dist/logging/ToolLogger.js.map +1 -1
  27. package/dist/logging/ToolLogger.test.js +171 -0
  28. package/dist/logging/ToolLogger.test.js.map +1 -1
  29. package/dist/service/Service.d.ts +88 -2
  30. package/dist/service/Service.d.ts.map +1 -1
  31. package/dist/service/Service.js +227 -55
  32. package/dist/service/Service.js.map +1 -1
  33. package/dist/service/Service.test.js +464 -36
  34. package/dist/service/Service.test.js.map +1 -1
  35. package/dist/types/ToolError.d.ts +59 -0
  36. package/dist/types/ToolError.d.ts.map +1 -0
  37. package/dist/types/ToolError.js +79 -0
  38. package/dist/types/ToolError.js.map +1 -0
  39. package/dist/types/ToolError.test.d.ts +2 -0
  40. package/dist/types/ToolError.test.d.ts.map +1 -0
  41. package/dist/types/ToolError.test.js +161 -0
  42. package/dist/types/ToolError.test.js.map +1 -0
  43. package/dist/validation/ParameterValidator.d.ts +5 -16
  44. package/dist/validation/ParameterValidator.d.ts.map +1 -1
  45. package/dist/validation/ParameterValidator.js +10 -3
  46. package/dist/validation/ParameterValidator.js.map +1 -1
  47. package/dist/validation/ParameterValidator.test.js +186 -146
  48. package/dist/validation/ParameterValidator.test.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/auth/AuthUtils.test.ts +176 -157
  51. package/src/auth/AuthUtils.ts +96 -33
  52. package/src/function/GlobalToolFunction.test.ts +54 -8
  53. package/src/function/GlobalToolFunction.ts +26 -6
  54. package/src/function/ToolFunction.test.ts +274 -8
  55. package/src/function/ToolFunction.ts +33 -7
  56. package/src/index.ts +1 -0
  57. package/src/logging/ToolLogger.test.ts +184 -0
  58. package/src/logging/ToolLogger.ts +13 -12
  59. package/src/service/Service.test.ts +577 -34
  60. package/src/service/Service.ts +286 -54
  61. package/src/types/ToolError.test.ts +192 -0
  62. package/src/types/ToolError.ts +95 -0
  63. package/src/validation/ParameterValidator.test.ts +185 -158
  64. package/src/validation/ParameterValidator.ts +17 -20
@@ -1,42 +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
- logger: {
9
- error: jest.fn()
10
- },
11
- Function: class {
12
- public constructor(public request: any) {}
13
- },
14
- Headers: class {
15
- public constructor(headers: string[][] = []) {
16
- this.headers = {};
17
- headers.forEach(([name, value]) => {
18
- this.headers[name.toLowerCase()] = value;
19
- });
20
- }
21
- public headers: { [key: string]: string };
22
- public set(name: string, value: string) {
23
- this.headers[name.toLowerCase()] = value;
24
- }
25
- public get(name: string) {
26
- return this.headers[name.toLowerCase()];
27
- }
28
- public has(name: string) {
29
- return name.toLowerCase() in this.headers;
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
30
  }
31
- },
32
- Response: jest.fn().mockImplementation((status, data, headers) => ({
33
- status,
34
- data,
35
- bodyJSON: data,
36
- bodyAsU8Array: new Uint8Array(),
37
- headers: headers || { get: jest.fn(), has: jest.fn(), set: jest.fn() }
38
- }))
39
- }));
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
+
40
78
 
41
79
  describe('ToolsService', () => {
42
80
  let mockTool: Tool<unknown>;
@@ -370,7 +408,7 @@ describe('ToolsService', () => {
370
408
  );
371
409
  });
372
410
 
373
- it('should return 500 error when tool handler throws an error', async () => {
411
+ it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
374
412
  const errorMessage = 'Tool execution failed';
375
413
  jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
376
414
 
@@ -378,6 +416,13 @@ describe('ToolsService', () => {
378
416
  const response = await toolsService.processRequest(mockRequest, mockToolFunction);
379
417
 
380
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');
381
426
  expect(logger.error).toHaveBeenCalledWith(
382
427
  `Error in function ${mockTool.name}:`,
383
428
  expect.any(Error)
@@ -391,6 +436,67 @@ describe('ToolsService', () => {
391
436
  const response = await toolsService.processRequest(mockRequest, mockToolFunction);
392
437
 
393
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');
394
500
  });
395
501
  });
396
502
 
@@ -488,7 +594,7 @@ describe('ToolsService', () => {
488
594
  );
489
595
  });
490
596
 
491
- it('should return 500 error when interaction handler throws an error', async () => {
597
+ it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
492
598
  const errorMessage = 'Interaction execution failed';
493
599
  jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
494
600
 
@@ -500,11 +606,43 @@ describe('ToolsService', () => {
500
606
  const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
501
607
 
502
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');
503
616
  expect(logger.error).toHaveBeenCalledWith(
504
617
  `Error in function ${mockInteraction.name}:`,
505
618
  expect.any(Error)
506
619
  );
507
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
+ });
508
646
  });
509
647
 
510
648
  describe('error cases', () => {
@@ -799,4 +937,409 @@ describe('ToolsService', () => {
799
937
  });
800
938
  });
801
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
+ });
1344
+ });
802
1345
  });