@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.2 → 1.0.0-beta.4

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 (67) hide show
  1. package/README.md +114 -0
  2. package/dist/auth/AuthUtils.d.ts +5 -5
  3. package/dist/auth/AuthUtils.d.ts.map +1 -1
  4. package/dist/auth/AuthUtils.js +53 -25
  5. package/dist/auth/AuthUtils.js.map +1 -1
  6. package/dist/auth/AuthUtils.test.js +62 -117
  7. package/dist/auth/AuthUtils.test.js.map +1 -1
  8. package/dist/function/GlobalToolFunction.d.ts +2 -1
  9. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  10. package/dist/function/GlobalToolFunction.js +25 -4
  11. package/dist/function/GlobalToolFunction.js.map +1 -1
  12. package/dist/function/GlobalToolFunction.test.js +57 -8
  13. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  14. package/dist/function/ToolFunction.d.ts +8 -2
  15. package/dist/function/ToolFunction.d.ts.map +1 -1
  16. package/dist/function/ToolFunction.js +31 -5
  17. package/dist/function/ToolFunction.js.map +1 -1
  18. package/dist/function/ToolFunction.test.js +57 -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 +34 -0
  25. package/dist/logging/ToolLogger.d.ts.map +1 -0
  26. package/dist/logging/ToolLogger.js +153 -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 +646 -0
  31. package/dist/logging/ToolLogger.test.js.map +1 -0
  32. package/dist/service/Service.d.ts +15 -2
  33. package/dist/service/Service.d.ts.map +1 -1
  34. package/dist/service/Service.js +43 -17
  35. package/dist/service/Service.js.map +1 -1
  36. package/dist/service/Service.test.js +84 -2
  37. package/dist/service/Service.test.js.map +1 -1
  38. package/dist/types/ToolError.d.ts +59 -0
  39. package/dist/types/ToolError.d.ts.map +1 -0
  40. package/dist/types/ToolError.js +79 -0
  41. package/dist/types/ToolError.js.map +1 -0
  42. package/dist/types/ToolError.test.d.ts +2 -0
  43. package/dist/types/ToolError.test.d.ts.map +1 -0
  44. package/dist/types/ToolError.test.js +161 -0
  45. package/dist/types/ToolError.test.js.map +1 -0
  46. package/dist/validation/ParameterValidator.d.ts +5 -16
  47. package/dist/validation/ParameterValidator.d.ts.map +1 -1
  48. package/dist/validation/ParameterValidator.js +10 -3
  49. package/dist/validation/ParameterValidator.js.map +1 -1
  50. package/dist/validation/ParameterValidator.test.js +186 -146
  51. package/dist/validation/ParameterValidator.test.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/auth/AuthUtils.test.ts +62 -157
  54. package/src/auth/AuthUtils.ts +66 -32
  55. package/src/function/GlobalToolFunction.test.ts +57 -8
  56. package/src/function/GlobalToolFunction.ts +37 -6
  57. package/src/function/ToolFunction.test.ts +57 -8
  58. package/src/function/ToolFunction.ts +45 -7
  59. package/src/index.ts +1 -0
  60. package/src/logging/ToolLogger.test.ts +753 -0
  61. package/src/logging/ToolLogger.ts +177 -0
  62. package/src/service/Service.test.ts +103 -2
  63. package/src/service/Service.ts +45 -17
  64. package/src/types/ToolError.test.ts +192 -0
  65. package/src/types/ToolError.ts +95 -0
  66. package/src/validation/ParameterValidator.test.ts +185 -158
  67. package/src/validation/ParameterValidator.ts +17 -20
@@ -0,0 +1,177 @@
1
+ import { logger, LogVisibility } from '@zaiusinc/app-sdk';
2
+ import * as App from '@zaiusinc/app-sdk';
3
+
4
+ /**
5
+ * Utility class for logging Opal tool requests and responses with security considerations
6
+ */
7
+ export class ToolLogger {
8
+ private static readonly SENSITIVE_FIELDS = [
9
+ // Authentication / secrets
10
+ 'password',
11
+ 'pass',
12
+ 'secret',
13
+ 'key',
14
+ 'token',
15
+ 'auth',
16
+ 'credentials',
17
+ 'access_token',
18
+ 'refresh_token',
19
+ 'api_key',
20
+ 'private_key',
21
+ 'client_secret',
22
+ 'session_token',
23
+ 'authorization',
24
+
25
+ // Payment-related
26
+ 'card_number',
27
+ 'credit_card',
28
+ 'cvv',
29
+ 'expiry_date',
30
+
31
+ // Personal info
32
+ 'ssn', // social security number
33
+ 'nid', // national ID
34
+ 'passport',
35
+ 'dob', // date of birth
36
+ 'email',
37
+ 'phone',
38
+ 'address',
39
+
40
+ // Misc / environment
41
+ 'otp',
42
+ 'pin',
43
+ 'security_answer',
44
+ 'security_question',
45
+ 'signing_key',
46
+ 'encryption_key',
47
+ 'jwt',
48
+ 'bearer_token'
49
+ ];
50
+
51
+ private static readonly MAX_PARAM_LENGTH = 100;
52
+ private static readonly MAX_ARRAY_ITEMS = 10;
53
+
54
+ /**
55
+ * Redacts sensitive data from an object
56
+ */
57
+ private static redactSensitiveData(data: any, maxDepth = 5): any {
58
+ if (maxDepth <= 0) {
59
+ return '[MAX_DEPTH_EXCEEDED]';
60
+ }
61
+
62
+ if (data === null || data === undefined) {
63
+ return data;
64
+ }
65
+
66
+ if (typeof data === 'string') {
67
+ return data.length > this.MAX_PARAM_LENGTH
68
+ ? `${data.substring(0, this.MAX_PARAM_LENGTH)}... (truncated, ${data.length} chars total)`
69
+ : data;
70
+ }
71
+
72
+ if (typeof data === 'number' || typeof data === 'boolean') {
73
+ return data;
74
+ }
75
+
76
+ if (Array.isArray(data)) {
77
+ const truncated = data.slice(0, this.MAX_ARRAY_ITEMS);
78
+ const result = truncated.map((item) => this.redactSensitiveData(item, maxDepth - 1));
79
+ if (data.length > this.MAX_ARRAY_ITEMS) {
80
+ result.push(`... (${data.length - this.MAX_ARRAY_ITEMS} more items truncated)`);
81
+ }
82
+ return result;
83
+ }
84
+
85
+ if (typeof data === 'object') {
86
+ const result: any = {};
87
+ for (const [key, value] of Object.entries(data)) {
88
+ // Check if this field contains sensitive data
89
+ const isSensitive = this.isSensitiveField(key);
90
+
91
+ if (isSensitive) {
92
+ result[key] = '[REDACTED]';
93
+ } else {
94
+ result[key] = this.redactSensitiveData(value, maxDepth - 1);
95
+ }
96
+ }
97
+ return result;
98
+ }
99
+
100
+ return data;
101
+ }
102
+
103
+ /**
104
+ * Checks if a field name is considered sensitive
105
+ */
106
+ private static isSensitiveField(fieldName: string): boolean {
107
+ const lowerKey = fieldName.toLowerCase();
108
+ return this.SENSITIVE_FIELDS.some((sensitiveField) =>
109
+ lowerKey.includes(sensitiveField)
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Creates a summary of request parameters
115
+ */
116
+ private static createParameterSummary(params: any): any {
117
+ if (!params) {
118
+ return null;
119
+ }
120
+
121
+ return this.redactSensitiveData(params);
122
+ }
123
+
124
+ /**
125
+ * Calculates content length of response data
126
+ */
127
+ private static calculateContentLength(response?: App.Response) {
128
+ if (!response) {
129
+ return 0;
130
+ }
131
+
132
+ try {
133
+ return response.bodyAsU8Array?.length || 'unknown';
134
+ } catch {
135
+ return 'unknown';
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Logs an incoming request
141
+ */
142
+ public static logRequest(
143
+ req: App.Request,
144
+ ): void {
145
+ const params = req.bodyJSON && req.bodyJSON.parameters ? req.bodyJSON.parameters : req.bodyJSON;
146
+ const requestLog = {
147
+ event: 'opal_tool_request',
148
+ path: req.path,
149
+ parameters: this.createParameterSummary(params)
150
+ };
151
+
152
+ // Log with Zaius audience so developers only see requests for accounts they have access to
153
+ logger.info(LogVisibility.Zaius, JSON.stringify(requestLog));
154
+ }
155
+
156
+ /**
157
+ * Logs a successful response
158
+ */
159
+ public static logResponse(
160
+ req: App.Request,
161
+ response: App.Response,
162
+ processingTimeMs?: number
163
+ ): void {
164
+ const responseLog = {
165
+ event: 'opal_tool_response',
166
+ path: req.path,
167
+ duration: processingTimeMs ? `${processingTimeMs}ms` : undefined,
168
+ status: response.status,
169
+ contentType: response.headers?.get('content-type') || 'unknown',
170
+ contentLength: this.calculateContentLength(response),
171
+ success: response.status >= 200 && response.status < 300
172
+ };
173
+
174
+ // Log with Zaius audience so developers only see requests for accounts they have access to
175
+ logger.info(LogVisibility.Zaius, JSON.stringify(responseLog));
176
+ }
177
+ }
@@ -1,5 +1,6 @@
1
1
  import { toolsService, Tool, Interaction } from './Service';
2
2
  import { Parameter, ParameterType, AuthRequirement, OptiIdAuthDataCredentials, OptiIdAuthData } from '../types/Models';
3
+ import { ToolError } from '../types/ToolError';
3
4
  import { ToolFunction } from '../function/ToolFunction';
4
5
  import { logger } from '@zaiusinc/app-sdk';
5
6
 
@@ -370,7 +371,7 @@ describe('ToolsService', () => {
370
371
  );
371
372
  });
372
373
 
373
- it('should return 500 error when tool handler throws an error', async () => {
374
+ it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
374
375
  const errorMessage = 'Tool execution failed';
375
376
  jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
376
377
 
@@ -378,6 +379,13 @@ describe('ToolsService', () => {
378
379
  const response = await toolsService.processRequest(mockRequest, mockToolFunction);
379
380
 
380
381
  expect(response.status).toBe(500);
382
+ expect(response.bodyJSON).toEqual({
383
+ title: 'Internal Server Error',
384
+ status: 500,
385
+ detail: errorMessage,
386
+ instance: mockTool.endpoint
387
+ });
388
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
381
389
  expect(logger.error).toHaveBeenCalledWith(
382
390
  `Error in function ${mockTool.name}:`,
383
391
  expect.any(Error)
@@ -391,6 +399,67 @@ describe('ToolsService', () => {
391
399
  const response = await toolsService.processRequest(mockRequest, mockToolFunction);
392
400
 
393
401
  expect(response.status).toBe(500);
402
+ expect(response.bodyJSON).toEqual({
403
+ title: 'Internal Server Error',
404
+ status: 500,
405
+ detail: 'An unexpected error occurred',
406
+ instance: mockTool.endpoint
407
+ });
408
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
409
+ });
410
+
411
+ it('should return custom status code when tool handler throws ToolError', async () => {
412
+ const toolError = new ToolError('Resource not found', 404, 'The requested task does not exist');
413
+ jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
414
+
415
+ const mockRequest = createMockRequest();
416
+ const response = await toolsService.processRequest(mockRequest, mockToolFunction);
417
+
418
+ expect(response.status).toBe(404);
419
+ expect(response.bodyJSON).toEqual({
420
+ title: 'Resource not found',
421
+ status: 404,
422
+ detail: 'The requested task does not exist',
423
+ instance: mockTool.endpoint
424
+ });
425
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
426
+ expect(logger.error).toHaveBeenCalledWith(
427
+ `Error in function ${mockTool.name}:`,
428
+ expect.any(ToolError)
429
+ );
430
+ });
431
+
432
+ it('should return ToolError without detail field when detail is not provided', async () => {
433
+ const toolError = new ToolError('Bad request', 400);
434
+ jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
435
+
436
+ const mockRequest = createMockRequest();
437
+ const response = await toolsService.processRequest(mockRequest, mockToolFunction);
438
+
439
+ expect(response.status).toBe(400);
440
+ expect(response.bodyJSON).toEqual({
441
+ title: 'Bad request',
442
+ status: 400,
443
+ instance: mockTool.endpoint
444
+ });
445
+ expect(response.bodyJSON).not.toHaveProperty('detail');
446
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
447
+ });
448
+
449
+ it('should default to 500 when ToolError is created without status', async () => {
450
+ const toolError = new ToolError('Database error');
451
+ jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
452
+
453
+ const mockRequest = createMockRequest();
454
+ const response = await toolsService.processRequest(mockRequest, mockToolFunction);
455
+
456
+ expect(response.status).toBe(500);
457
+ expect(response.bodyJSON).toEqual({
458
+ title: 'Database error',
459
+ status: 500,
460
+ instance: mockTool.endpoint
461
+ });
462
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
394
463
  });
395
464
  });
396
465
 
@@ -488,7 +557,7 @@ describe('ToolsService', () => {
488
557
  );
489
558
  });
490
559
 
491
- it('should return 500 error when interaction handler throws an error', async () => {
560
+ it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
492
561
  const errorMessage = 'Interaction execution failed';
493
562
  jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
494
563
 
@@ -500,11 +569,43 @@ describe('ToolsService', () => {
500
569
  const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
501
570
 
502
571
  expect(response.status).toBe(500);
572
+ expect(response.bodyJSON).toEqual({
573
+ title: 'Internal Server Error',
574
+ status: 500,
575
+ detail: errorMessage,
576
+ instance: mockInteraction.endpoint
577
+ });
578
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
503
579
  expect(logger.error).toHaveBeenCalledWith(
504
580
  `Error in function ${mockInteraction.name}:`,
505
581
  expect.any(Error)
506
582
  );
507
583
  });
584
+
585
+ it('should return custom status code when interaction handler throws ToolError', async () => {
586
+ const toolError = new ToolError('Webhook validation failed', 400, 'Invalid signature');
587
+ jest.mocked(mockInteraction.handler).mockRejectedValueOnce(toolError);
588
+
589
+ const interactionRequest = createMockRequest({
590
+ path: '/test-interaction',
591
+ bodyJSON: { data: { param1: 'test-value' } }
592
+ });
593
+
594
+ const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
595
+
596
+ expect(response.status).toBe(400);
597
+ expect(response.bodyJSON).toEqual({
598
+ title: 'Webhook validation failed',
599
+ status: 400,
600
+ detail: 'Invalid signature',
601
+ instance: mockInteraction.endpoint
602
+ });
603
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
604
+ expect(logger.error).toHaveBeenCalledWith(
605
+ `Error in function ${mockInteraction.name}:`,
606
+ expect.any(ToolError)
607
+ );
608
+ });
508
609
  });
509
610
 
510
611
  describe('error cases', () => {
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable max-classes-per-file */
2
- import { AuthRequirement, Parameter } from '../types/Models';
2
+ import { AuthRequirement, IslandResponse, Parameter } from '../types/Models';
3
+ import { ToolError } from '../types/ToolError';
3
4
  import * as App from '@zaiusinc/app-sdk';
4
5
  import { logger, Headers } from '@zaiusinc/app-sdk';
5
6
  import { ToolFunction } from '../function/ToolFunction';
@@ -11,13 +12,20 @@ import { ParameterValidator } from '../validation/ParameterValidator';
11
12
  */
12
13
  const DEFAULT_OPTIID_AUTH = new AuthRequirement('OptiID', 'default', true);
13
14
 
15
+ export class NestedInteractions {
16
+ public constructor(public response: IslandResponse) {
17
+ }
18
+ }
19
+
14
20
  /**
15
21
  * Result type for interaction handlers
16
22
  */
17
23
  export class InteractionResult {
18
24
  public constructor(
19
25
  public message: string,
20
- public link?: string
26
+ public link?: string,
27
+ public dispatch_event?: boolean,
28
+ public interactions?: NestedInteractions
21
29
  ) {}
22
30
  }
23
31
 
@@ -104,6 +112,37 @@ export class ToolsService {
104
112
  return [...(authRequirements || []), DEFAULT_OPTIID_AUTH];
105
113
  }
106
114
 
115
+ /**
116
+ * Format an error as RFC 9457 Problem Details response
117
+ * @param error The error to format
118
+ * @param instance URI reference identifying the specific occurrence
119
+ * @returns RFC 9457 compliant Response
120
+ */
121
+ private formatErrorResponse(error: any, instance: string): App.Response {
122
+ let status = 500;
123
+ let problemDetails: Record<string, unknown>;
124
+
125
+ if (error instanceof ToolError) {
126
+ // Use ToolError's status and format
127
+ status = error.status;
128
+ problemDetails = error.toProblemDetails(instance);
129
+ } else {
130
+ // Convert regular errors to RFC 9457 format with 500 status
131
+ problemDetails = {
132
+ title: 'Internal Server Error',
133
+ status: 500,
134
+ detail: error.message || 'An unexpected error occurred',
135
+ instance
136
+ };
137
+ }
138
+
139
+ return new App.Response(
140
+ status,
141
+ problemDetails,
142
+ new Headers([['content-type', 'application/problem+json']])
143
+ );
144
+ }
145
+
107
146
  /**
108
147
  * Register a tool function with generic auth data
109
148
  * @param name Tool name
@@ -175,20 +214,9 @@ export class ToolsService {
175
214
  }
176
215
 
177
216
  // Validate parameters before calling the handler (only if tool has parameter definitions)
217
+ // ParameterValidator.validate() throws ToolError if validation fails
178
218
  if (func.parameters && func.parameters.length > 0) {
179
- const validationResult = ParameterValidator.validate(params, func.parameters);
180
- if (!validationResult.isValid) {
181
- return new App.Response(400, {
182
- title: 'One or more validation errors occurred.',
183
- status: 400,
184
- detail: "See 'errors' field for details.",
185
- instance: func.endpoint,
186
- errors: validationResult.errors.map((error) => ({
187
- field: error.field,
188
- message: error.message
189
- }))
190
- }, new Headers([['content-type', 'application/problem+json']]));
191
- }
219
+ ParameterValidator.validate(params, func.parameters, func.endpoint);
192
220
  }
193
221
 
194
222
  // Extract auth data from body JSON
@@ -198,7 +226,7 @@ export class ToolsService {
198
226
  return new App.Response(200, result);
199
227
  } catch (error: any) {
200
228
  logger.error(`Error in function ${func.name}:`, error);
201
- return new App.Response(500, { error: error.message || 'Unknown error' });
229
+ return this.formatErrorResponse(error, func.endpoint);
202
230
  }
203
231
  }
204
232
 
@@ -219,7 +247,7 @@ export class ToolsService {
219
247
  return new App.Response(200, result);
220
248
  } catch (error: any) {
221
249
  logger.error(`Error in function ${interaction.name}:`, error);
222
- return new App.Response(500, { error: error.message || 'Unknown error' });
250
+ return this.formatErrorResponse(error, interaction.endpoint);
223
251
  }
224
252
  }
225
253
 
@@ -0,0 +1,192 @@
1
+ import { ToolError } from './ToolError';
2
+
3
+ describe('ToolError', () => {
4
+ describe('constructor', () => {
5
+ it('should create error with message and default status 500', () => {
6
+ const error = new ToolError('Something went wrong');
7
+
8
+ expect(error.message).toBe('Something went wrong');
9
+ expect(error.status).toBe(500);
10
+ expect(error.detail).toBeUndefined();
11
+ expect(error.name).toBe('ToolError');
12
+ });
13
+
14
+ it('should create error with custom status code', () => {
15
+ const error = new ToolError('Not found', 404);
16
+
17
+ expect(error.message).toBe('Not found');
18
+ expect(error.status).toBe(404);
19
+ expect(error.detail).toBeUndefined();
20
+ });
21
+
22
+ it('should create error with message, status, and detail', () => {
23
+ const error = new ToolError('Validation failed', 400, 'Email format is invalid');
24
+
25
+ expect(error.message).toBe('Validation failed');
26
+ expect(error.status).toBe(400);
27
+ expect(error.detail).toBe('Email format is invalid');
28
+ });
29
+
30
+ it('should be an instance of Error', () => {
31
+ const error = new ToolError('Test error');
32
+
33
+ expect(error).toBeInstanceOf(Error);
34
+ expect(error).toBeInstanceOf(ToolError);
35
+ });
36
+
37
+ it('should have a stack trace', () => {
38
+ const error = new ToolError('Test error');
39
+
40
+ expect(error.stack).toBeDefined();
41
+ expect(error.stack).toContain('ToolError');
42
+ });
43
+ });
44
+
45
+ describe('toProblemDetails', () => {
46
+ it('should convert to RFC 9457 format with all fields', () => {
47
+ const error = new ToolError('Resource not found', 404, 'The task with ID 123 does not exist');
48
+ const problemDetails = error.toProblemDetails('/api/tasks/123');
49
+
50
+ expect(problemDetails).toEqual({
51
+ title: 'Resource not found',
52
+ status: 404,
53
+ detail: 'The task with ID 123 does not exist',
54
+ instance: '/api/tasks/123'
55
+ });
56
+ });
57
+
58
+ it('should omit detail field when not provided', () => {
59
+ const error = new ToolError('Bad request', 400);
60
+ const problemDetails = error.toProblemDetails('/api/tasks');
61
+
62
+ expect(problemDetails).toEqual({
63
+ title: 'Bad request',
64
+ status: 400,
65
+ instance: '/api/tasks'
66
+ });
67
+ expect(problemDetails).not.toHaveProperty('detail');
68
+ });
69
+
70
+ it('should include default status 500', () => {
71
+ const error = new ToolError('Internal error');
72
+ const problemDetails = error.toProblemDetails('/api/endpoint');
73
+
74
+ expect(problemDetails).toEqual({
75
+ title: 'Internal error',
76
+ status: 500,
77
+ instance: '/api/endpoint'
78
+ });
79
+ });
80
+
81
+ it('should handle different instance paths', () => {
82
+ const error = new ToolError('Error', 500, 'Details');
83
+ const problemDetails1 = error.toProblemDetails('/api/v1/resource');
84
+ const problemDetails2 = error.toProblemDetails('/webhook/callback');
85
+
86
+ expect(problemDetails1.instance).toBe('/api/v1/resource');
87
+ expect(problemDetails2.instance).toBe('/webhook/callback');
88
+ });
89
+
90
+ it('should include errors array when provided', () => {
91
+ const errors = [
92
+ { field: 'email', message: 'Invalid email format' },
93
+ { field: 'age', message: 'Must be a positive number' }
94
+ ];
95
+ const error = new ToolError('Validation failed', 400, "See 'errors' field for details.", errors);
96
+ const problemDetails = error.toProblemDetails('/api/users');
97
+
98
+ expect(problemDetails).toEqual({
99
+ title: 'Validation failed',
100
+ status: 400,
101
+ detail: "See 'errors' field for details.",
102
+ instance: '/api/users',
103
+ errors: [
104
+ { field: 'email', message: 'Invalid email format' },
105
+ { field: 'age', message: 'Must be a positive number' }
106
+ ]
107
+ });
108
+ });
109
+
110
+ it('should omit errors field when empty array', () => {
111
+ const error = new ToolError('Error', 400, 'Detail', []);
112
+ const problemDetails = error.toProblemDetails('/api/endpoint');
113
+
114
+ expect(problemDetails).toEqual({
115
+ title: 'Error',
116
+ status: 400,
117
+ detail: 'Detail',
118
+ instance: '/api/endpoint'
119
+ });
120
+ expect(problemDetails).not.toHaveProperty('errors');
121
+ });
122
+
123
+ it('should omit errors field when not provided', () => {
124
+ const error = new ToolError('Error', 400, 'Detail');
125
+ const problemDetails = error.toProblemDetails('/api/endpoint');
126
+
127
+ expect(problemDetails).toEqual({
128
+ title: 'Error',
129
+ status: 400,
130
+ detail: 'Detail',
131
+ instance: '/api/endpoint'
132
+ });
133
+ expect(problemDetails).not.toHaveProperty('errors');
134
+ });
135
+
136
+ it('should support errors without detail field', () => {
137
+ const errors = [{ field: 'name', message: 'Required' }];
138
+ const error = new ToolError('Validation failed', 400, undefined, errors);
139
+ const problemDetails = error.toProblemDetails('/api/endpoint');
140
+
141
+ expect(problemDetails).toEqual({
142
+ title: 'Validation failed',
143
+ status: 400,
144
+ instance: '/api/endpoint',
145
+ errors: [{ field: 'name', message: 'Required' }]
146
+ });
147
+ expect(problemDetails).not.toHaveProperty('detail');
148
+ });
149
+ });
150
+
151
+ describe('common HTTP status codes', () => {
152
+ it('should support 400 Bad Request', () => {
153
+ const error = new ToolError('Bad Request', 400);
154
+ expect(error.status).toBe(400);
155
+ });
156
+
157
+ it('should support 401 Unauthorized', () => {
158
+ const error = new ToolError('Unauthorized', 401);
159
+ expect(error.status).toBe(401);
160
+ });
161
+
162
+ it('should support 403 Forbidden', () => {
163
+ const error = new ToolError('Forbidden', 403);
164
+ expect(error.status).toBe(403);
165
+ });
166
+
167
+ it('should support 404 Not Found', () => {
168
+ const error = new ToolError('Not Found', 404);
169
+ expect(error.status).toBe(404);
170
+ });
171
+
172
+ it('should support 409 Conflict', () => {
173
+ const error = new ToolError('Conflict', 409);
174
+ expect(error.status).toBe(409);
175
+ });
176
+
177
+ it('should support 422 Unprocessable Entity', () => {
178
+ const error = new ToolError('Unprocessable Entity', 422);
179
+ expect(error.status).toBe(422);
180
+ });
181
+
182
+ it('should support 500 Internal Server Error', () => {
183
+ const error = new ToolError('Internal Server Error', 500);
184
+ expect(error.status).toBe(500);
185
+ });
186
+
187
+ it('should support 503 Service Unavailable', () => {
188
+ const error = new ToolError('Service Unavailable', 503);
189
+ expect(error.status).toBe(503);
190
+ });
191
+ });
192
+ });