@jgardner04/ghost-mcp-server 1.1.8 → 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
|
@@ -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
|
});
|