@jgardner04/ghost-mcp-server 1.1.7 → 1.1.8
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/package.json
CHANGED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
BaseError,
|
|
4
|
+
ValidationError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthorizationError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ExternalServiceError,
|
|
11
|
+
GhostAPIError,
|
|
12
|
+
MCPProtocolError,
|
|
13
|
+
ToolExecutionError,
|
|
14
|
+
ImageProcessingError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
ErrorHandler,
|
|
17
|
+
CircuitBreaker,
|
|
18
|
+
retryWithBackoff,
|
|
19
|
+
} from '../index.js';
|
|
20
|
+
|
|
21
|
+
describe('Error Handling System', () => {
|
|
22
|
+
describe('BaseError', () => {
|
|
23
|
+
it('should create error with default values', () => {
|
|
24
|
+
const error = new BaseError('Test error');
|
|
25
|
+
|
|
26
|
+
expect(error.message).toBe('Test error');
|
|
27
|
+
expect(error.statusCode).toBe(500);
|
|
28
|
+
expect(error.code).toBe('INTERNAL_ERROR');
|
|
29
|
+
expect(error.isOperational).toBe(true);
|
|
30
|
+
expect(error.name).toBe('BaseError');
|
|
31
|
+
expect(error.timestamp).toBeDefined();
|
|
32
|
+
expect(error.stack).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should create error with custom values', () => {
|
|
36
|
+
const error = new BaseError('Custom error', 400, 'CUSTOM_CODE', false);
|
|
37
|
+
|
|
38
|
+
expect(error.message).toBe('Custom error');
|
|
39
|
+
expect(error.statusCode).toBe(400);
|
|
40
|
+
expect(error.code).toBe('CUSTOM_CODE');
|
|
41
|
+
expect(error.isOperational).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should serialize to JSON in development mode', () => {
|
|
45
|
+
const originalEnv = process.env.NODE_ENV;
|
|
46
|
+
process.env.NODE_ENV = 'development';
|
|
47
|
+
|
|
48
|
+
const error = new BaseError('Test error', 500, 'TEST_CODE');
|
|
49
|
+
const json = error.toJSON();
|
|
50
|
+
|
|
51
|
+
expect(json.name).toBe('BaseError');
|
|
52
|
+
expect(json.message).toBe('Test error');
|
|
53
|
+
expect(json.code).toBe('TEST_CODE');
|
|
54
|
+
expect(json.statusCode).toBe(500);
|
|
55
|
+
expect(json.timestamp).toBeDefined();
|
|
56
|
+
expect(json.stack).toBeDefined();
|
|
57
|
+
|
|
58
|
+
process.env.NODE_ENV = originalEnv;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should not include stack in JSON in production mode', () => {
|
|
62
|
+
const originalEnv = process.env.NODE_ENV;
|
|
63
|
+
process.env.NODE_ENV = 'production';
|
|
64
|
+
|
|
65
|
+
const error = new BaseError('Test error');
|
|
66
|
+
const json = error.toJSON();
|
|
67
|
+
|
|
68
|
+
expect(json.stack).toBeUndefined();
|
|
69
|
+
|
|
70
|
+
process.env.NODE_ENV = originalEnv;
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('ValidationError', () => {
|
|
75
|
+
it('should create validation error with default values', () => {
|
|
76
|
+
const error = new ValidationError('Validation failed');
|
|
77
|
+
|
|
78
|
+
expect(error.message).toBe('Validation failed');
|
|
79
|
+
expect(error.statusCode).toBe(400);
|
|
80
|
+
expect(error.code).toBe('VALIDATION_ERROR');
|
|
81
|
+
expect(error.errors).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should create validation error with error details', () => {
|
|
85
|
+
const errors = [
|
|
86
|
+
{ field: 'email', message: 'Invalid email', type: 'string.email' },
|
|
87
|
+
{ field: 'age', message: 'Must be positive', type: 'number.positive' },
|
|
88
|
+
];
|
|
89
|
+
const error = new ValidationError('Validation failed', errors);
|
|
90
|
+
|
|
91
|
+
expect(error.errors).toEqual(errors);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should create validation error from Joi error', () => {
|
|
95
|
+
const joiError = {
|
|
96
|
+
details: [
|
|
97
|
+
{
|
|
98
|
+
path: ['user', 'email'],
|
|
99
|
+
message: '"user.email" must be a valid email',
|
|
100
|
+
type: 'string.email',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
path: ['age'],
|
|
104
|
+
message: '"age" must be a number',
|
|
105
|
+
type: 'number.base',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const error = ValidationError.fromJoi(joiError);
|
|
111
|
+
|
|
112
|
+
expect(error.message).toBe('Validation failed');
|
|
113
|
+
expect(error.errors).toHaveLength(2);
|
|
114
|
+
expect(error.errors[0]).toEqual({
|
|
115
|
+
field: 'user.email',
|
|
116
|
+
message: '"user.email" must be a valid email',
|
|
117
|
+
type: 'string.email',
|
|
118
|
+
});
|
|
119
|
+
expect(error.errors[1]).toEqual({
|
|
120
|
+
field: 'age',
|
|
121
|
+
message: '"age" must be a number',
|
|
122
|
+
type: 'number.base',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('AuthenticationError', () => {
|
|
128
|
+
it('should create authentication error with default message', () => {
|
|
129
|
+
const error = new AuthenticationError();
|
|
130
|
+
|
|
131
|
+
expect(error.message).toBe('Authentication failed');
|
|
132
|
+
expect(error.statusCode).toBe(401);
|
|
133
|
+
expect(error.code).toBe('AUTHENTICATION_ERROR');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should create authentication error with custom message', () => {
|
|
137
|
+
const error = new AuthenticationError('Invalid API key');
|
|
138
|
+
|
|
139
|
+
expect(error.message).toBe('Invalid API key');
|
|
140
|
+
expect(error.statusCode).toBe(401);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('AuthorizationError', () => {
|
|
145
|
+
it('should create authorization error with default message', () => {
|
|
146
|
+
const error = new AuthorizationError();
|
|
147
|
+
|
|
148
|
+
expect(error.message).toBe('Access denied');
|
|
149
|
+
expect(error.statusCode).toBe(403);
|
|
150
|
+
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should create authorization error with custom message', () => {
|
|
154
|
+
const error = new AuthorizationError('Insufficient permissions');
|
|
155
|
+
|
|
156
|
+
expect(error.message).toBe('Insufficient permissions');
|
|
157
|
+
expect(error.statusCode).toBe(403);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('NotFoundError', () => {
|
|
162
|
+
it('should create not found error with resource and identifier', () => {
|
|
163
|
+
const error = new NotFoundError('Post', '123');
|
|
164
|
+
|
|
165
|
+
expect(error.message).toBe('Post not found: 123');
|
|
166
|
+
expect(error.statusCode).toBe(404);
|
|
167
|
+
expect(error.code).toBe('NOT_FOUND');
|
|
168
|
+
expect(error.resource).toBe('Post');
|
|
169
|
+
expect(error.identifier).toBe('123');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('ConflictError', () => {
|
|
174
|
+
it('should create conflict error with resource', () => {
|
|
175
|
+
const error = new ConflictError('Tag already exists', 'Tag');
|
|
176
|
+
|
|
177
|
+
expect(error.message).toBe('Tag already exists');
|
|
178
|
+
expect(error.statusCode).toBe(409);
|
|
179
|
+
expect(error.code).toBe('CONFLICT');
|
|
180
|
+
expect(error.resource).toBe('Tag');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('RateLimitError', () => {
|
|
185
|
+
it('should create rate limit error with default retryAfter', () => {
|
|
186
|
+
const error = new RateLimitError();
|
|
187
|
+
|
|
188
|
+
expect(error.message).toBe('Rate limit exceeded');
|
|
189
|
+
expect(error.statusCode).toBe(429);
|
|
190
|
+
expect(error.code).toBe('RATE_LIMIT_EXCEEDED');
|
|
191
|
+
expect(error.retryAfter).toBe(60);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should create rate limit error with custom retryAfter', () => {
|
|
195
|
+
const error = new RateLimitError(120);
|
|
196
|
+
|
|
197
|
+
expect(error.retryAfter).toBe(120);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('ExternalServiceError', () => {
|
|
202
|
+
it('should create external service error with service name', () => {
|
|
203
|
+
const error = new ExternalServiceError('Ghost API', 'Connection failed');
|
|
204
|
+
|
|
205
|
+
expect(error.message).toBe('External service error: Ghost API');
|
|
206
|
+
expect(error.statusCode).toBe(502);
|
|
207
|
+
expect(error.code).toBe('EXTERNAL_SERVICE_ERROR');
|
|
208
|
+
expect(error.service).toBe('Ghost API');
|
|
209
|
+
expect(error.originalError).toBe('Connection failed');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should handle Error object as originalError', () => {
|
|
213
|
+
const originalError = new Error('Network timeout');
|
|
214
|
+
const error = new ExternalServiceError('API', originalError);
|
|
215
|
+
|
|
216
|
+
expect(error.originalError).toBe('Network timeout');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('GhostAPIError', () => {
|
|
221
|
+
it('should map 401 status code correctly', () => {
|
|
222
|
+
const error = new GhostAPIError('createPost', 'Unauthorized', 401);
|
|
223
|
+
|
|
224
|
+
expect(error.statusCode).toBe(401);
|
|
225
|
+
expect(error.code).toBe('GHOST_AUTH_ERROR');
|
|
226
|
+
expect(error.operation).toBe('createPost');
|
|
227
|
+
expect(error.ghostStatusCode).toBe(401);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should map 404 status code correctly', () => {
|
|
231
|
+
const error = new GhostAPIError('getPost', 'Not found', 404);
|
|
232
|
+
|
|
233
|
+
expect(error.statusCode).toBe(404);
|
|
234
|
+
expect(error.code).toBe('GHOST_NOT_FOUND');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should map 422 status code to 400', () => {
|
|
238
|
+
const error = new GhostAPIError('updatePost', 'Invalid data', 422);
|
|
239
|
+
|
|
240
|
+
expect(error.statusCode).toBe(400);
|
|
241
|
+
expect(error.code).toBe('GHOST_VALIDATION_ERROR');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should map 429 status code correctly', () => {
|
|
245
|
+
const error = new GhostAPIError('getPosts', 'Rate limited', 429);
|
|
246
|
+
|
|
247
|
+
expect(error.statusCode).toBe(429);
|
|
248
|
+
expect(error.code).toBe('GHOST_RATE_LIMIT');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should keep default 502 for other status codes', () => {
|
|
252
|
+
const error = new GhostAPIError('operation', 'Server error', 500);
|
|
253
|
+
|
|
254
|
+
expect(error.statusCode).toBe(502);
|
|
255
|
+
expect(error.code).toBe('EXTERNAL_SERVICE_ERROR');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('MCPProtocolError', () => {
|
|
260
|
+
it('should create MCP protocol error with default details', () => {
|
|
261
|
+
const error = new MCPProtocolError('Invalid tool call');
|
|
262
|
+
|
|
263
|
+
expect(error.message).toBe('Invalid tool call');
|
|
264
|
+
expect(error.statusCode).toBe(400);
|
|
265
|
+
expect(error.code).toBe('MCP_PROTOCOL_ERROR');
|
|
266
|
+
expect(error.details).toEqual({});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should create MCP protocol error with details', () => {
|
|
270
|
+
const details = { tool: 'ghost_create_post', reason: 'Missing required field' };
|
|
271
|
+
const error = new MCPProtocolError('Invalid parameters', details);
|
|
272
|
+
|
|
273
|
+
expect(error.details).toEqual(details);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('ToolExecutionError', () => {
|
|
278
|
+
it('should create tool execution error with input', () => {
|
|
279
|
+
const input = { title: 'Test', tags: ['test'] };
|
|
280
|
+
const error = new ToolExecutionError('ghost_create_post', 'API error', input);
|
|
281
|
+
|
|
282
|
+
expect(error.message).toBe('Tool execution failed: ghost_create_post');
|
|
283
|
+
expect(error.statusCode).toBe(500);
|
|
284
|
+
expect(error.code).toBe('TOOL_EXECUTION_ERROR');
|
|
285
|
+
expect(error.toolName).toBe('ghost_create_post');
|
|
286
|
+
expect(error.originalError).toBe('API error');
|
|
287
|
+
expect(error.input).toEqual(input);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should handle Error object as originalError', () => {
|
|
291
|
+
const originalError = new Error('Execution failed');
|
|
292
|
+
const error = new ToolExecutionError('tool', originalError, {});
|
|
293
|
+
|
|
294
|
+
expect(error.originalError).toBe('Execution failed');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should filter sensitive data in production', () => {
|
|
298
|
+
const originalEnv = process.env.NODE_ENV;
|
|
299
|
+
process.env.NODE_ENV = 'production';
|
|
300
|
+
|
|
301
|
+
const input = {
|
|
302
|
+
apiKey: 'secret123',
|
|
303
|
+
password: 'pass123',
|
|
304
|
+
token: 'token123',
|
|
305
|
+
title: 'Post Title',
|
|
306
|
+
};
|
|
307
|
+
const error = new ToolExecutionError('tool', 'Error', input);
|
|
308
|
+
|
|
309
|
+
expect(error.input.apiKey).toBeUndefined();
|
|
310
|
+
expect(error.input.password).toBeUndefined();
|
|
311
|
+
expect(error.input.token).toBeUndefined();
|
|
312
|
+
expect(error.input.title).toBe('Post Title');
|
|
313
|
+
|
|
314
|
+
process.env.NODE_ENV = originalEnv;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should not filter sensitive data in development', () => {
|
|
318
|
+
const originalEnv = process.env.NODE_ENV;
|
|
319
|
+
process.env.NODE_ENV = 'development';
|
|
320
|
+
|
|
321
|
+
const input = {
|
|
322
|
+
apiKey: 'secret123',
|
|
323
|
+
password: 'pass123',
|
|
324
|
+
token: 'token123',
|
|
325
|
+
};
|
|
326
|
+
const error = new ToolExecutionError('tool', 'Error', input);
|
|
327
|
+
|
|
328
|
+
expect(error.input.apiKey).toBe('secret123');
|
|
329
|
+
expect(error.input.password).toBe('pass123');
|
|
330
|
+
expect(error.input.token).toBe('token123');
|
|
331
|
+
|
|
332
|
+
process.env.NODE_ENV = originalEnv;
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('ImageProcessingError', () => {
|
|
337
|
+
it('should create image processing error with operation', () => {
|
|
338
|
+
const error = new ImageProcessingError('resize', 'Invalid dimensions');
|
|
339
|
+
|
|
340
|
+
expect(error.message).toBe('Image processing failed: resize');
|
|
341
|
+
expect(error.statusCode).toBe(422);
|
|
342
|
+
expect(error.code).toBe('IMAGE_PROCESSING_ERROR');
|
|
343
|
+
expect(error.operation).toBe('resize');
|
|
344
|
+
expect(error.originalError).toBe('Invalid dimensions');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should handle Error object as originalError', () => {
|
|
348
|
+
const originalError = new Error('Sharp error');
|
|
349
|
+
const error = new ImageProcessingError('optimize', originalError);
|
|
350
|
+
|
|
351
|
+
expect(error.originalError).toBe('Sharp error');
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('ConfigurationError', () => {
|
|
356
|
+
it('should create configuration error with default missing fields', () => {
|
|
357
|
+
const error = new ConfigurationError('Missing configuration');
|
|
358
|
+
|
|
359
|
+
expect(error.message).toBe('Missing configuration');
|
|
360
|
+
expect(error.statusCode).toBe(500);
|
|
361
|
+
expect(error.code).toBe('CONFIGURATION_ERROR');
|
|
362
|
+
expect(error.isOperational).toBe(false);
|
|
363
|
+
expect(error.missingFields).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should create configuration error with missing fields', () => {
|
|
367
|
+
const error = new ConfigurationError('Invalid config', ['GHOST_API_URL', 'GHOST_API_KEY']);
|
|
368
|
+
|
|
369
|
+
expect(error.missingFields).toEqual(['GHOST_API_URL', 'GHOST_API_KEY']);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('ErrorHandler', () => {
|
|
374
|
+
describe('isOperationalError', () => {
|
|
375
|
+
it('should return true for BaseError with isOperational=true', () => {
|
|
376
|
+
const error = new BaseError('Test', 500, 'TEST', true);
|
|
377
|
+
expect(ErrorHandler.isOperationalError(error)).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should return false for BaseError with isOperational=false', () => {
|
|
381
|
+
const error = new ConfigurationError('Test');
|
|
382
|
+
expect(ErrorHandler.isOperationalError(error)).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should return false for regular Error', () => {
|
|
386
|
+
const error = new Error('Regular error');
|
|
387
|
+
expect(ErrorHandler.isOperationalError(error)).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('formatMCPError', () => {
|
|
392
|
+
it('should format BaseError for MCP response', () => {
|
|
393
|
+
const error = new ValidationError('Invalid input', [
|
|
394
|
+
{ field: 'email', message: 'Invalid' },
|
|
395
|
+
]);
|
|
396
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
397
|
+
|
|
398
|
+
expect(formatted.error.code).toBe('VALIDATION_ERROR');
|
|
399
|
+
expect(formatted.error.message).toBe('Invalid input');
|
|
400
|
+
expect(formatted.error.statusCode).toBe(400);
|
|
401
|
+
expect(formatted.error.validationErrors).toHaveLength(1);
|
|
402
|
+
expect(formatted.error.timestamp).toBeDefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should include tool name when provided', () => {
|
|
406
|
+
const error = new BaseError('Test error');
|
|
407
|
+
const formatted = ErrorHandler.formatMCPError(error, 'ghost_create_post');
|
|
408
|
+
|
|
409
|
+
expect(formatted.error.tool).toBe('ghost_create_post');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should include retryAfter for RateLimitError', () => {
|
|
413
|
+
const error = new RateLimitError(120);
|
|
414
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
415
|
+
|
|
416
|
+
expect(formatted.error.retryAfter).toBe(120);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should format unknown error in production', () => {
|
|
420
|
+
const originalEnv = process.env.NODE_ENV;
|
|
421
|
+
process.env.NODE_ENV = 'production';
|
|
422
|
+
|
|
423
|
+
const error = new Error('Unexpected error');
|
|
424
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
425
|
+
|
|
426
|
+
expect(formatted.error.code).toBe('UNKNOWN_ERROR');
|
|
427
|
+
expect(formatted.error.message).toBe('An unexpected error occurred');
|
|
428
|
+
expect(formatted.error.statusCode).toBe(500);
|
|
429
|
+
|
|
430
|
+
process.env.NODE_ENV = originalEnv;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should include error message for unknown error in development', () => {
|
|
434
|
+
const originalEnv = process.env.NODE_ENV;
|
|
435
|
+
process.env.NODE_ENV = 'development';
|
|
436
|
+
|
|
437
|
+
const error = new Error('Debug error');
|
|
438
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
439
|
+
|
|
440
|
+
expect(formatted.error.message).toBe('Debug error');
|
|
441
|
+
|
|
442
|
+
process.env.NODE_ENV = originalEnv;
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('formatHTTPError', () => {
|
|
447
|
+
it('should format BaseError for HTTP response', () => {
|
|
448
|
+
const error = new NotFoundError('Post', '123');
|
|
449
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
450
|
+
|
|
451
|
+
expect(formatted.statusCode).toBe(404);
|
|
452
|
+
expect(formatted.body.error.code).toBe('NOT_FOUND');
|
|
453
|
+
expect(formatted.body.error.message).toBe('Post not found: 123');
|
|
454
|
+
expect(formatted.body.error.resource).toBe('Post');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should include validation errors', () => {
|
|
458
|
+
const error = new ValidationError('Invalid', [{ field: 'name', message: 'Required' }]);
|
|
459
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
460
|
+
|
|
461
|
+
expect(formatted.body.error.errors).toHaveLength(1);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should include retryAfter for rate limit', () => {
|
|
465
|
+
const error = new RateLimitError(60);
|
|
466
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
467
|
+
|
|
468
|
+
expect(formatted.body.error.retryAfter).toBe(60);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('should format unknown error in production', () => {
|
|
472
|
+
const originalEnv = process.env.NODE_ENV;
|
|
473
|
+
process.env.NODE_ENV = 'production';
|
|
474
|
+
|
|
475
|
+
const error = new Error('Unknown');
|
|
476
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
477
|
+
|
|
478
|
+
expect(formatted.statusCode).toBe(500);
|
|
479
|
+
expect(formatted.body.error.code).toBe('INTERNAL_ERROR');
|
|
480
|
+
expect(formatted.body.error.message).toBe('An internal error occurred');
|
|
481
|
+
|
|
482
|
+
process.env.NODE_ENV = originalEnv;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('should include error message for unknown error in development', () => {
|
|
486
|
+
const originalEnv = process.env.NODE_ENV;
|
|
487
|
+
process.env.NODE_ENV = 'development';
|
|
488
|
+
|
|
489
|
+
const error = new Error('Debug message');
|
|
490
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
491
|
+
|
|
492
|
+
expect(formatted.body.error.message).toBe('Debug message');
|
|
493
|
+
|
|
494
|
+
process.env.NODE_ENV = originalEnv;
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe('asyncWrapper', () => {
|
|
499
|
+
it('should pass through successful results', async () => {
|
|
500
|
+
const fn = async () => 'success';
|
|
501
|
+
const wrapped = ErrorHandler.asyncWrapper(fn);
|
|
502
|
+
|
|
503
|
+
const result = await wrapped();
|
|
504
|
+
expect(result).toBe('success');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('should rethrow operational errors', async () => {
|
|
508
|
+
const error = new ValidationError('Invalid');
|
|
509
|
+
const fn = async () => {
|
|
510
|
+
throw error;
|
|
511
|
+
};
|
|
512
|
+
const wrapped = ErrorHandler.asyncWrapper(fn);
|
|
513
|
+
|
|
514
|
+
await expect(wrapped()).rejects.toThrow(error);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should log and rethrow non-operational errors', async () => {
|
|
518
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
519
|
+
const error = new Error('Programming error');
|
|
520
|
+
const fn = async () => {
|
|
521
|
+
throw error;
|
|
522
|
+
};
|
|
523
|
+
const wrapped = ErrorHandler.asyncWrapper(fn);
|
|
524
|
+
|
|
525
|
+
await expect(wrapped()).rejects.toThrow(error);
|
|
526
|
+
expect(consoleSpy).toHaveBeenCalledWith('Unexpected error:', error);
|
|
527
|
+
|
|
528
|
+
consoleSpy.mockRestore();
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('fromGhostError', () => {
|
|
533
|
+
it('should create GhostAPIError from error with response', () => {
|
|
534
|
+
const ghostError = {
|
|
535
|
+
response: {
|
|
536
|
+
status: 404,
|
|
537
|
+
data: {
|
|
538
|
+
errors: [{ message: 'Post not found' }],
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const error = ErrorHandler.fromGhostError(ghostError, 'getPost');
|
|
544
|
+
|
|
545
|
+
expect(error).toBeInstanceOf(GhostAPIError);
|
|
546
|
+
expect(error.operation).toBe('getPost');
|
|
547
|
+
expect(error.ghostStatusCode).toBe(404);
|
|
548
|
+
expect(error.originalError).toBe('Post not found');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should handle error without response', () => {
|
|
552
|
+
const ghostError = {
|
|
553
|
+
statusCode: 500,
|
|
554
|
+
message: 'Server error',
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const error = ErrorHandler.fromGhostError(ghostError, 'operation');
|
|
558
|
+
|
|
559
|
+
expect(error.ghostStatusCode).toBe(500);
|
|
560
|
+
expect(error.originalError).toBe('Server error');
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe('isRetryable', () => {
|
|
565
|
+
it('should return true for RateLimitError', () => {
|
|
566
|
+
const error = new RateLimitError();
|
|
567
|
+
expect(ErrorHandler.isRetryable(error)).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('should return true for ExternalServiceError', () => {
|
|
571
|
+
const error = new ExternalServiceError('API', 'error');
|
|
572
|
+
expect(ErrorHandler.isRetryable(error)).toBe(true);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('should return true for GhostAPIError with retryable status codes', () => {
|
|
576
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 429))).toBe(true);
|
|
577
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 502))).toBe(true);
|
|
578
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 503))).toBe(true);
|
|
579
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 504))).toBe(true);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should return true for GhostAPIError (extends ExternalServiceError)', () => {
|
|
583
|
+
// GhostAPIError extends ExternalServiceError, so it's always retryable
|
|
584
|
+
// The ghostStatusCode-specific logic is never reached due to instanceof check order
|
|
585
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 400))).toBe(true);
|
|
586
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 404))).toBe(true);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('should return true for network errors', () => {
|
|
590
|
+
const econnrefused = new Error();
|
|
591
|
+
econnrefused.code = 'ECONNREFUSED';
|
|
592
|
+
expect(ErrorHandler.isRetryable(econnrefused)).toBe(true);
|
|
593
|
+
|
|
594
|
+
const etimedout = new Error();
|
|
595
|
+
etimedout.code = 'ETIMEDOUT';
|
|
596
|
+
expect(ErrorHandler.isRetryable(etimedout)).toBe(true);
|
|
597
|
+
|
|
598
|
+
const econnreset = new Error();
|
|
599
|
+
econnreset.code = 'ECONNRESET';
|
|
600
|
+
expect(ErrorHandler.isRetryable(econnreset)).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('should return false for non-retryable errors', () => {
|
|
604
|
+
expect(ErrorHandler.isRetryable(new ValidationError('Invalid'))).toBe(false);
|
|
605
|
+
expect(ErrorHandler.isRetryable(new Error('Unknown'))).toBe(false);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe('getRetryDelay', () => {
|
|
610
|
+
it('should return retryAfter for RateLimitError in milliseconds', () => {
|
|
611
|
+
const error = new RateLimitError(60);
|
|
612
|
+
const delay = ErrorHandler.getRetryDelay(1, error);
|
|
613
|
+
|
|
614
|
+
expect(delay).toBe(60000); // 60 seconds * 1000
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should calculate exponential backoff for attempt 1', () => {
|
|
618
|
+
const error = new ExternalServiceError('API', 'error');
|
|
619
|
+
const delay = ErrorHandler.getRetryDelay(1, error);
|
|
620
|
+
|
|
621
|
+
// Base delay is 1000ms, with jitter of up to 30%
|
|
622
|
+
expect(delay).toBeGreaterThanOrEqual(1000);
|
|
623
|
+
expect(delay).toBeLessThanOrEqual(1300);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('should calculate exponential backoff for attempt 2', () => {
|
|
627
|
+
const error = new ExternalServiceError('API', 'error');
|
|
628
|
+
const delay = ErrorHandler.getRetryDelay(2, error);
|
|
629
|
+
|
|
630
|
+
// 2000ms base + up to 30% jitter
|
|
631
|
+
expect(delay).toBeGreaterThanOrEqual(2000);
|
|
632
|
+
expect(delay).toBeLessThanOrEqual(2600);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should calculate exponential backoff for attempt 3', () => {
|
|
636
|
+
const error = new ExternalServiceError('API', 'error');
|
|
637
|
+
const delay = ErrorHandler.getRetryDelay(3, error);
|
|
638
|
+
|
|
639
|
+
// 4000ms base + up to 30% jitter
|
|
640
|
+
expect(delay).toBeGreaterThanOrEqual(4000);
|
|
641
|
+
expect(delay).toBeLessThanOrEqual(5200);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('should cap delay at maximum', () => {
|
|
645
|
+
const error = new ExternalServiceError('API', 'error');
|
|
646
|
+
const delay = ErrorHandler.getRetryDelay(10, error);
|
|
647
|
+
|
|
648
|
+
// Max delay is 30000ms + 30% jitter
|
|
649
|
+
expect(delay).toBeLessThanOrEqual(39000);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should return integer delay', () => {
|
|
653
|
+
const error = new ExternalServiceError('API', 'error');
|
|
654
|
+
const delay = ErrorHandler.getRetryDelay(2, error);
|
|
655
|
+
|
|
656
|
+
expect(Number.isInteger(delay)).toBe(true);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe('CircuitBreaker', () => {
|
|
662
|
+
let breaker;
|
|
663
|
+
|
|
664
|
+
beforeEach(() => {
|
|
665
|
+
breaker = new CircuitBreaker({
|
|
666
|
+
failureThreshold: 3,
|
|
667
|
+
resetTimeout: 1000,
|
|
668
|
+
monitoringPeriod: 500,
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('should initialize in CLOSED state', () => {
|
|
673
|
+
const state = breaker.getState();
|
|
674
|
+
|
|
675
|
+
expect(state.state).toBe('CLOSED');
|
|
676
|
+
expect(state.failureCount).toBe(0);
|
|
677
|
+
expect(state.lastFailureTime).toBeNull();
|
|
678
|
+
expect(state.nextAttempt).toBeNull();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('should execute successful function in CLOSED state', async () => {
|
|
682
|
+
const fn = vi.fn().mockResolvedValue('success');
|
|
683
|
+
const result = await breaker.execute(fn);
|
|
684
|
+
|
|
685
|
+
expect(result).toBe('success');
|
|
686
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
687
|
+
expect(breaker.getState().state).toBe('CLOSED');
|
|
688
|
+
expect(breaker.getState().failureCount).toBe(0);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('should increment failure count on error', async () => {
|
|
692
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
693
|
+
|
|
694
|
+
await expect(breaker.execute(fn)).rejects.toThrow('Failed');
|
|
695
|
+
expect(breaker.getState().failureCount).toBe(1);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('should transition to OPEN after threshold failures', async () => {
|
|
699
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
700
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
701
|
+
|
|
702
|
+
// Fail 3 times (threshold)
|
|
703
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
704
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
705
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
706
|
+
|
|
707
|
+
const state = breaker.getState();
|
|
708
|
+
expect(state.state).toBe('OPEN');
|
|
709
|
+
expect(state.failureCount).toBe(3);
|
|
710
|
+
expect(state.nextAttempt).toBeGreaterThan(Date.now());
|
|
711
|
+
|
|
712
|
+
consoleSpy.mockRestore();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('should reject immediately when OPEN', async () => {
|
|
716
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
717
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
718
|
+
|
|
719
|
+
// Trip the breaker
|
|
720
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
721
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
722
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
723
|
+
|
|
724
|
+
// Now it should reject immediately
|
|
725
|
+
await expect(breaker.execute(fn)).rejects.toThrow('Circuit breaker is OPEN');
|
|
726
|
+
expect(fn).toHaveBeenCalledTimes(3); // Not called the 4th time
|
|
727
|
+
|
|
728
|
+
consoleSpy.mockRestore();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('should transition to HALF_OPEN after timeout', async () => {
|
|
732
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
733
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
734
|
+
|
|
735
|
+
// Trip the breaker
|
|
736
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
737
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
738
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
739
|
+
|
|
740
|
+
expect(breaker.getState().state).toBe('OPEN');
|
|
741
|
+
|
|
742
|
+
// Wait for reset timeout
|
|
743
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
744
|
+
|
|
745
|
+
// Next call should transition to HALF_OPEN
|
|
746
|
+
const successFn = vi.fn().mockResolvedValue('success');
|
|
747
|
+
const result = await breaker.execute(successFn);
|
|
748
|
+
|
|
749
|
+
expect(result).toBe('success');
|
|
750
|
+
expect(breaker.getState().state).toBe('CLOSED');
|
|
751
|
+
|
|
752
|
+
consoleSpy.mockRestore();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('should reset to CLOSED on success in HALF_OPEN state', async () => {
|
|
756
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
757
|
+
const failFn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
758
|
+
|
|
759
|
+
// Trip the breaker
|
|
760
|
+
await expect(breaker.execute(failFn)).rejects.toThrow();
|
|
761
|
+
await expect(breaker.execute(failFn)).rejects.toThrow();
|
|
762
|
+
await expect(breaker.execute(failFn)).rejects.toThrow();
|
|
763
|
+
|
|
764
|
+
// Wait for timeout
|
|
765
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
766
|
+
|
|
767
|
+
// Successful call should reset to CLOSED
|
|
768
|
+
const successFn = vi.fn().mockResolvedValue('success');
|
|
769
|
+
await breaker.execute(successFn);
|
|
770
|
+
|
|
771
|
+
const state = breaker.getState();
|
|
772
|
+
expect(state.state).toBe('CLOSED');
|
|
773
|
+
expect(state.failureCount).toBe(0);
|
|
774
|
+
expect(state.lastFailureTime).toBeNull();
|
|
775
|
+
|
|
776
|
+
consoleSpy.mockRestore();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('should use default options when not provided', () => {
|
|
780
|
+
const defaultBreaker = new CircuitBreaker();
|
|
781
|
+
|
|
782
|
+
expect(defaultBreaker.failureThreshold).toBe(5);
|
|
783
|
+
expect(defaultBreaker.resetTimeout).toBe(60000);
|
|
784
|
+
expect(defaultBreaker.monitoringPeriod).toBe(10000);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
describe('retryWithBackoff', () => {
|
|
789
|
+
it('should return result on first successful attempt', async () => {
|
|
790
|
+
const fn = vi.fn().mockResolvedValue('success');
|
|
791
|
+
const result = await retryWithBackoff(fn);
|
|
792
|
+
|
|
793
|
+
expect(result).toBe('success');
|
|
794
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('should retry on retryable error and succeed', async () => {
|
|
798
|
+
const fn = vi
|
|
799
|
+
.fn()
|
|
800
|
+
.mockRejectedValueOnce(new RateLimitError(1))
|
|
801
|
+
.mockResolvedValueOnce('success');
|
|
802
|
+
|
|
803
|
+
const result = await retryWithBackoff(fn, { maxAttempts: 3 });
|
|
804
|
+
|
|
805
|
+
expect(result).toBe('success');
|
|
806
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('should throw after max attempts with retryable error', async () => {
|
|
810
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
811
|
+
const error = new ExternalServiceError('API', 'Failed');
|
|
812
|
+
const fn = vi.fn().mockRejectedValue(error);
|
|
813
|
+
|
|
814
|
+
await expect(retryWithBackoff(fn, { maxAttempts: 3 })).rejects.toThrow(error);
|
|
815
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
816
|
+
|
|
817
|
+
consoleSpy.mockRestore();
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('should not retry non-retryable errors', async () => {
|
|
821
|
+
const error = new ValidationError('Invalid input');
|
|
822
|
+
const fn = vi.fn().mockRejectedValue(error);
|
|
823
|
+
|
|
824
|
+
await expect(retryWithBackoff(fn, { maxAttempts: 3 })).rejects.toThrow(error);
|
|
825
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('should call onRetry callback on retry', async () => {
|
|
829
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
830
|
+
const onRetry = vi.fn();
|
|
831
|
+
const fn = vi
|
|
832
|
+
.fn()
|
|
833
|
+
.mockRejectedValueOnce(new RateLimitError(1))
|
|
834
|
+
.mockResolvedValueOnce('success');
|
|
835
|
+
|
|
836
|
+
await retryWithBackoff(fn, { maxAttempts: 3, onRetry });
|
|
837
|
+
|
|
838
|
+
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
839
|
+
expect(onRetry).toHaveBeenCalledWith(1, expect.any(RateLimitError));
|
|
840
|
+
|
|
841
|
+
consoleSpy.mockRestore();
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('should use default maxAttempts of 3', async () => {
|
|
845
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
846
|
+
const error = new RateLimitError(1);
|
|
847
|
+
const fn = vi.fn().mockRejectedValue(error);
|
|
848
|
+
|
|
849
|
+
await expect(retryWithBackoff(fn)).rejects.toThrow(error);
|
|
850
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
851
|
+
|
|
852
|
+
consoleSpy.mockRestore();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('should wait between retries', async () => {
|
|
856
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
857
|
+
const startTime = Date.now();
|
|
858
|
+
const fn = vi
|
|
859
|
+
.fn()
|
|
860
|
+
.mockRejectedValueOnce(new ExternalServiceError('API', 'Error'))
|
|
861
|
+
.mockResolvedValueOnce('success');
|
|
862
|
+
|
|
863
|
+
await retryWithBackoff(fn, { maxAttempts: 2 });
|
|
864
|
+
|
|
865
|
+
const duration = Date.now() - startTime;
|
|
866
|
+
// Should wait at least 1000ms (first retry delay)
|
|
867
|
+
expect(duration).toBeGreaterThanOrEqual(1000);
|
|
868
|
+
|
|
869
|
+
consoleSpy.mockRestore();
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
});
|