@jgardner04/ghost-mcp-server 1.1.7 → 1.1.9
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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
|
|
3
3
|
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
4
4
|
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
@@ -15,9 +15,218 @@ vi.mock('../../utils/logger.js', () => ({
|
|
|
15
15
|
}));
|
|
16
16
|
|
|
17
17
|
// Import after setting up mocks and environment
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
createPost,
|
|
20
|
+
createTag,
|
|
21
|
+
getTags,
|
|
22
|
+
getSiteInfo,
|
|
23
|
+
uploadImage,
|
|
24
|
+
handleApiRequest,
|
|
25
|
+
api,
|
|
26
|
+
} from '../ghostService.js';
|
|
19
27
|
|
|
20
28
|
describe('ghostService', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
// Reset all mocks before each test
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('handleApiRequest', () => {
|
|
35
|
+
describe('success paths', () => {
|
|
36
|
+
it('should handle add action for posts successfully', async () => {
|
|
37
|
+
const expectedPost = { id: '1', title: 'Test Post', status: 'draft' };
|
|
38
|
+
api.posts.add.mockResolvedValue(expectedPost);
|
|
39
|
+
|
|
40
|
+
const result = await handleApiRequest('posts', 'add', { title: 'Test Post' });
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual(expectedPost);
|
|
43
|
+
expect(api.posts.add).toHaveBeenCalledWith({ title: 'Test Post' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle add action for tags successfully', async () => {
|
|
47
|
+
const expectedTag = { id: '1', name: 'Test Tag', slug: 'test-tag' };
|
|
48
|
+
api.tags.add.mockResolvedValue(expectedTag);
|
|
49
|
+
|
|
50
|
+
const result = await handleApiRequest('tags', 'add', { name: 'Test Tag' });
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual(expectedTag);
|
|
53
|
+
expect(api.tags.add).toHaveBeenCalledWith({ name: 'Test Tag' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should handle upload action for images successfully', async () => {
|
|
57
|
+
const expectedImage = { url: 'https://example.com/image.jpg' };
|
|
58
|
+
api.images.upload.mockResolvedValue(expectedImage);
|
|
59
|
+
|
|
60
|
+
const result = await handleApiRequest('images', 'upload', { file: '/path/to/image.jpg' });
|
|
61
|
+
|
|
62
|
+
expect(result).toEqual(expectedImage);
|
|
63
|
+
expect(api.images.upload).toHaveBeenCalledWith({ file: '/path/to/image.jpg' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should handle browse action with options successfully', async () => {
|
|
67
|
+
const expectedTags = [
|
|
68
|
+
{ id: '1', name: 'Tag1' },
|
|
69
|
+
{ id: '2', name: 'Tag2' },
|
|
70
|
+
];
|
|
71
|
+
api.tags.browse.mockResolvedValue(expectedTags);
|
|
72
|
+
|
|
73
|
+
const result = await handleApiRequest('tags', 'browse', {}, { limit: 'all' });
|
|
74
|
+
|
|
75
|
+
expect(result).toEqual(expectedTags);
|
|
76
|
+
expect(api.tags.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should handle add action with options successfully', async () => {
|
|
80
|
+
const postData = { title: 'Test Post', html: '<p>Content</p>' };
|
|
81
|
+
const options = { source: 'html' };
|
|
82
|
+
const expectedPost = { id: '1', ...postData };
|
|
83
|
+
api.posts.add.mockResolvedValue(expectedPost);
|
|
84
|
+
|
|
85
|
+
const result = await handleApiRequest('posts', 'add', postData, options);
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual(expectedPost);
|
|
88
|
+
expect(api.posts.add).toHaveBeenCalledWith(postData, options);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('error handling', () => {
|
|
93
|
+
it('should throw error for invalid resource', async () => {
|
|
94
|
+
await expect(handleApiRequest('invalid', 'add', {})).rejects.toThrow(
|
|
95
|
+
'Invalid Ghost API resource or action: invalid.add'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should throw error for invalid action', async () => {
|
|
100
|
+
await expect(handleApiRequest('posts', 'invalid', {})).rejects.toThrow(
|
|
101
|
+
'Invalid Ghost API resource or action: posts.invalid'
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should handle 404 error and throw', async () => {
|
|
106
|
+
const error404 = new Error('Not found');
|
|
107
|
+
error404.response = { status: 404 };
|
|
108
|
+
api.posts.read.mockRejectedValue(error404);
|
|
109
|
+
|
|
110
|
+
await expect(handleApiRequest('posts', 'read', { id: 'nonexistent' })).rejects.toThrow(
|
|
111
|
+
'Not found'
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('retry logic', () => {
|
|
117
|
+
it('should retry on 429 rate limit error with delay', async () => {
|
|
118
|
+
const rateLimitError = new Error('Rate limit exceeded');
|
|
119
|
+
rateLimitError.response = { status: 429 };
|
|
120
|
+
const successResult = { id: '1', name: 'Success' };
|
|
121
|
+
|
|
122
|
+
api.tags.add.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(successResult);
|
|
123
|
+
|
|
124
|
+
const startTime = Date.now();
|
|
125
|
+
const result = await handleApiRequest('tags', 'add', { name: 'Test' });
|
|
126
|
+
const elapsedTime = Date.now() - startTime;
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual(successResult);
|
|
129
|
+
expect(api.tags.add).toHaveBeenCalledTimes(2);
|
|
130
|
+
// Should have delayed at least 5000ms for rate limit
|
|
131
|
+
expect(elapsedTime).toBeGreaterThanOrEqual(4900); // Allow small margin
|
|
132
|
+
}, 10000); // 10 second timeout
|
|
133
|
+
|
|
134
|
+
it('should retry on 500 server error with increasing delay', async () => {
|
|
135
|
+
const serverError = new Error('Internal server error');
|
|
136
|
+
serverError.response = { status: 500 };
|
|
137
|
+
const successResult = { id: '1', title: 'Success' };
|
|
138
|
+
|
|
139
|
+
api.posts.add.mockRejectedValueOnce(serverError).mockResolvedValueOnce(successResult);
|
|
140
|
+
|
|
141
|
+
const result = await handleApiRequest('posts', 'add', { title: 'Test' });
|
|
142
|
+
|
|
143
|
+
expect(result).toEqual(successResult);
|
|
144
|
+
expect(api.posts.add).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should retry on ECONNREFUSED network error', async () => {
|
|
148
|
+
const networkError = new Error('Connection refused');
|
|
149
|
+
networkError.code = 'ECONNREFUSED';
|
|
150
|
+
const successResult = { id: '1', title: 'Success' };
|
|
151
|
+
|
|
152
|
+
api.posts.add.mockRejectedValueOnce(networkError).mockResolvedValueOnce(successResult);
|
|
153
|
+
|
|
154
|
+
const result = await handleApiRequest('posts', 'add', { title: 'Test' });
|
|
155
|
+
|
|
156
|
+
expect(result).toEqual(successResult);
|
|
157
|
+
expect(api.posts.add).toHaveBeenCalledTimes(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should retry on ETIMEDOUT network error', async () => {
|
|
161
|
+
const timeoutError = new Error('Connection timeout');
|
|
162
|
+
timeoutError.code = 'ETIMEDOUT';
|
|
163
|
+
const successResult = { id: '1', title: 'Success' };
|
|
164
|
+
|
|
165
|
+
api.posts.add.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(successResult);
|
|
166
|
+
|
|
167
|
+
const result = await handleApiRequest('posts', 'add', { title: 'Test' });
|
|
168
|
+
|
|
169
|
+
expect(result).toEqual(successResult);
|
|
170
|
+
expect(api.posts.add).toHaveBeenCalledTimes(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should throw error after exhausting retries', async () => {
|
|
174
|
+
const serverError = new Error('Server error');
|
|
175
|
+
serverError.response = { status: 500 };
|
|
176
|
+
|
|
177
|
+
api.posts.add.mockRejectedValue(serverError);
|
|
178
|
+
|
|
179
|
+
// Should retry 3 times (4 attempts total)
|
|
180
|
+
await expect(handleApiRequest('posts', 'add', { title: 'Test' })).rejects.toThrow(
|
|
181
|
+
'Server error'
|
|
182
|
+
);
|
|
183
|
+
expect(api.posts.add).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
|
184
|
+
}, 10000); // 10 second timeout
|
|
185
|
+
|
|
186
|
+
it('should not retry on non-retryable errors', async () => {
|
|
187
|
+
const badRequestError = new Error('Bad request');
|
|
188
|
+
badRequestError.response = { status: 400 };
|
|
189
|
+
|
|
190
|
+
api.posts.add.mockRejectedValue(badRequestError);
|
|
191
|
+
|
|
192
|
+
await expect(handleApiRequest('posts', 'add', { title: 'Test' })).rejects.toThrow(
|
|
193
|
+
'Bad request'
|
|
194
|
+
);
|
|
195
|
+
expect(api.posts.add).toHaveBeenCalledTimes(1); // No retries
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('getSiteInfo', () => {
|
|
201
|
+
it('should successfully retrieve site information', async () => {
|
|
202
|
+
const expectedSite = { title: 'Test Site', url: 'https://example.com' };
|
|
203
|
+
api.site.read.mockResolvedValue(expectedSite);
|
|
204
|
+
|
|
205
|
+
const result = await getSiteInfo();
|
|
206
|
+
|
|
207
|
+
expect(result).toEqual(expectedSite);
|
|
208
|
+
expect(api.site.read).toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('uploadImage', () => {
|
|
213
|
+
it('should throw error when image path is missing', async () => {
|
|
214
|
+
await expect(uploadImage()).rejects.toThrow('Image path is required for upload');
|
|
215
|
+
await expect(uploadImage('')).rejects.toThrow('Image path is required for upload');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should successfully upload image with valid path', async () => {
|
|
219
|
+
const imagePath = '/path/to/image.jpg';
|
|
220
|
+
const expectedResult = { url: 'https://example.com/uploaded-image.jpg' };
|
|
221
|
+
api.images.upload.mockResolvedValue(expectedResult);
|
|
222
|
+
|
|
223
|
+
const result = await uploadImage(imagePath);
|
|
224
|
+
|
|
225
|
+
expect(result).toEqual(expectedResult);
|
|
226
|
+
expect(api.images.upload).toHaveBeenCalledWith({ file: imagePath });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
21
230
|
describe('createPost', () => {
|
|
22
231
|
it('should throw error when title is missing', async () => {
|
|
23
232
|
await expect(createPost({})).rejects.toThrow('Post title is required');
|
|
@@ -25,15 +234,27 @@ describe('ghostService', () => {
|
|
|
25
234
|
|
|
26
235
|
it('should set default status to draft when not provided', async () => {
|
|
27
236
|
const postData = { title: 'Test Post', html: '<p>Content</p>' };
|
|
237
|
+
const expectedPost = { id: '1', title: 'Test Post', html: '<p>Content</p>', status: 'draft' };
|
|
238
|
+
api.posts.add.mockResolvedValue(expectedPost);
|
|
28
239
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
240
|
+
const result = await createPost(postData);
|
|
241
|
+
|
|
242
|
+
expect(result).toEqual(expectedPost);
|
|
243
|
+
expect(api.posts.add).toHaveBeenCalledWith(
|
|
244
|
+
{ status: 'draft', title: 'Test Post', html: '<p>Content</p>' },
|
|
245
|
+
{ source: 'html' }
|
|
246
|
+
);
|
|
247
|
+
});
|
|
35
248
|
|
|
36
|
-
|
|
249
|
+
it('should successfully create post with valid data', async () => {
|
|
250
|
+
const postData = { title: 'Test Post', html: '<p>Content</p>', status: 'published' };
|
|
251
|
+
const expectedPost = { id: '1', ...postData };
|
|
252
|
+
api.posts.add.mockResolvedValue(expectedPost);
|
|
253
|
+
|
|
254
|
+
const result = await createPost(postData);
|
|
255
|
+
|
|
256
|
+
expect(result).toEqual(expectedPost);
|
|
257
|
+
expect(api.posts.add).toHaveBeenCalled();
|
|
37
258
|
});
|
|
38
259
|
});
|
|
39
260
|
|
|
@@ -42,16 +263,15 @@ describe('ghostService', () => {
|
|
|
42
263
|
await expect(createTag({})).rejects.toThrow('Tag name is required');
|
|
43
264
|
});
|
|
44
265
|
|
|
45
|
-
it('should
|
|
266
|
+
it('should successfully create tag with valid data', async () => {
|
|
46
267
|
const tagData = { name: 'Test Tag', slug: 'test-tag' };
|
|
268
|
+
const expectedTag = { id: '1', ...tagData };
|
|
269
|
+
api.tags.add.mockResolvedValue(expectedTag);
|
|
47
270
|
|
|
48
|
-
|
|
49
|
-
await createTag(tagData);
|
|
50
|
-
} catch (_error) {
|
|
51
|
-
// Expected to fail with mock, but validates input handling
|
|
52
|
-
}
|
|
271
|
+
const result = await createTag(tagData);
|
|
53
272
|
|
|
54
|
-
expect(
|
|
273
|
+
expect(result).toEqual(expectedTag);
|
|
274
|
+
expect(api.tags.add).toHaveBeenCalledWith(tagData);
|
|
55
275
|
});
|
|
56
276
|
});
|
|
57
277
|
|
|
@@ -64,26 +284,36 @@ describe('ghostService', () => {
|
|
|
64
284
|
|
|
65
285
|
it('should accept valid tag names', async () => {
|
|
66
286
|
const validNames = ['Test Tag', 'test-tag', 'test_tag', 'Tag123'];
|
|
287
|
+
const expectedTags = [{ id: '1', name: 'Tag' }];
|
|
288
|
+
api.tags.browse.mockResolvedValue(expectedTags);
|
|
67
289
|
|
|
68
290
|
for (const name of validNames) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} catch (_error) {
|
|
72
|
-
// Expected to fail with mock, but should not throw validation error
|
|
73
|
-
expect(_error.message).not.toContain('invalid characters');
|
|
74
|
-
}
|
|
291
|
+
const result = await getTags(name);
|
|
292
|
+
expect(result).toEqual(expectedTags);
|
|
75
293
|
}
|
|
76
294
|
});
|
|
77
295
|
|
|
78
|
-
it('should handle
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
296
|
+
it('should handle tags without filter when name is not provided', async () => {
|
|
297
|
+
const expectedTags = [
|
|
298
|
+
{ id: '1', name: 'Tag1' },
|
|
299
|
+
{ id: '2', name: 'Tag2' },
|
|
300
|
+
];
|
|
301
|
+
api.tags.browse.mockResolvedValue(expectedTags);
|
|
302
|
+
|
|
303
|
+
const result = await getTags();
|
|
304
|
+
|
|
305
|
+
expect(result).toEqual(expectedTags);
|
|
306
|
+
expect(api.tags.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should properly escape tag names in filter', async () => {
|
|
310
|
+
const expectedTags = [{ id: '1', name: "Tag's Name" }];
|
|
311
|
+
api.tags.browse.mockResolvedValue(expectedTags);
|
|
312
|
+
|
|
313
|
+
// This should work because we properly escape single quotes
|
|
314
|
+
const result = await getTags('Valid Tag');
|
|
84
315
|
|
|
85
|
-
|
|
86
|
-
expect(true).toBe(true);
|
|
316
|
+
expect(result).toEqual(expectedTags);
|
|
87
317
|
});
|
|
88
318
|
});
|
|
89
319
|
});
|