@jgardner04/ghost-mcp-server 1.1.12 → 1.2.0

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.
@@ -0,0 +1,381 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Store tool implementations for testing
4
+ const toolImplementations = {};
5
+
6
+ // Mock MCP SDK
7
+ vi.mock('@modelcontextprotocol/sdk/server/index.js', () => {
8
+ const mockServerInstance = {
9
+ addResource: vi.fn(),
10
+ addTool: vi.fn(),
11
+ listen: vi.fn().mockResolvedValue(undefined),
12
+ listResources: vi.fn().mockReturnValue([{ name: 'ghost/tag' }, { name: 'ghost/post' }]),
13
+ listTools: vi
14
+ .fn()
15
+ .mockReturnValue([
16
+ { name: 'ghost_create_post' },
17
+ { name: 'ghost_upload_image' },
18
+ { name: 'ghost_get_tags' },
19
+ { name: 'ghost_create_tag' },
20
+ ]),
21
+ };
22
+
23
+ return {
24
+ MCPServer: class MockMCPServer {
25
+ constructor() {
26
+ Object.assign(this, mockServerInstance);
27
+ }
28
+
29
+ static getInstance() {
30
+ return mockServerInstance;
31
+ }
32
+ },
33
+ Resource: class MockResource {
34
+ constructor(config) {
35
+ this.name = config.name;
36
+ this.description = config.description;
37
+ this.schema = config.schema;
38
+ }
39
+ },
40
+ Tool: class MockTool {
41
+ constructor(config) {
42
+ toolImplementations[config.name] = config.implementation;
43
+ this.name = config.name;
44
+ this.description = config.description;
45
+ this.inputSchema = config.inputSchema;
46
+ this.outputSchema = config.outputSchema;
47
+ this.implementation = config.implementation;
48
+ }
49
+ },
50
+ };
51
+ });
52
+
53
+ // Mock dotenv
54
+ vi.mock('dotenv', () => ({
55
+ default: { config: vi.fn() },
56
+ }));
57
+
58
+ // Mock services
59
+ const mockCreatePostService = vi.fn();
60
+ const mockUploadGhostImage = vi.fn();
61
+ const mockGetGhostTags = vi.fn();
62
+ const mockCreateGhostTag = vi.fn();
63
+ const mockProcessImage = vi.fn();
64
+
65
+ vi.mock('../services/postService.js', () => ({
66
+ createPostService: (...args) => mockCreatePostService(...args),
67
+ }));
68
+
69
+ vi.mock('../services/ghostService.js', () => ({
70
+ uploadImage: (...args) => mockUploadGhostImage(...args),
71
+ getTags: (...args) => mockGetGhostTags(...args),
72
+ createTag: (...args) => mockCreateGhostTag(...args),
73
+ }));
74
+
75
+ vi.mock('../services/imageProcessingService.js', () => ({
76
+ processImage: (...args) => mockProcessImage(...args),
77
+ }));
78
+
79
+ // Mock axios
80
+ const mockAxios = vi.fn();
81
+ vi.mock('axios', () => ({
82
+ default: (...args) => mockAxios(...args),
83
+ }));
84
+
85
+ // Mock fs
86
+ const mockUnlink = vi.fn((path, cb) => cb(null));
87
+ const mockCreateWriteStream = vi.fn();
88
+ vi.mock('fs', () => ({
89
+ default: {
90
+ unlink: (...args) => mockUnlink(...args),
91
+ createWriteStream: (...args) => mockCreateWriteStream(...args),
92
+ },
93
+ }));
94
+
95
+ // Mock os
96
+ vi.mock('os', () => ({
97
+ default: { tmpdir: vi.fn().mockReturnValue('/tmp') },
98
+ }));
99
+
100
+ // Mock uuid
101
+ vi.mock('uuid', () => ({
102
+ v4: vi.fn().mockReturnValue('test-uuid-1234'),
103
+ }));
104
+
105
+ // Mock urlValidator
106
+ const mockValidateImageUrl = vi.fn();
107
+ const mockCreateSecureAxiosConfig = vi.fn();
108
+ vi.mock('../utils/urlValidator.js', () => ({
109
+ validateImageUrl: (...args) => mockValidateImageUrl(...args),
110
+ createSecureAxiosConfig: (...args) => mockCreateSecureAxiosConfig(...args),
111
+ }));
112
+
113
+ // Mock logger
114
+ vi.mock('../utils/logger.js', () => ({
115
+ createContextLogger: vi.fn().mockReturnValue({
116
+ info: vi.fn(),
117
+ error: vi.fn(),
118
+ warn: vi.fn(),
119
+ debug: vi.fn(),
120
+ toolExecution: vi.fn(),
121
+ toolSuccess: vi.fn(),
122
+ toolError: vi.fn(),
123
+ fileOperation: vi.fn(),
124
+ }),
125
+ }));
126
+
127
+ describe('mcp_server', () => {
128
+ let mcpServerModule;
129
+
130
+ beforeEach(async () => {
131
+ vi.clearAllMocks();
132
+ // Import module once to register tools
133
+ if (!mcpServerModule) {
134
+ mcpServerModule = await import('../mcp_server.js');
135
+ }
136
+ });
137
+
138
+ describe('ghost_create_post tool', () => {
139
+ it('should call createPostService with input and return result', async () => {
140
+ const createdPost = {
141
+ id: '1',
142
+ title: 'Test Post',
143
+ html: '<p>Content</p>',
144
+ status: 'draft',
145
+ };
146
+ mockCreatePostService.mockResolvedValue(createdPost);
147
+
148
+ const input = { title: 'Test Post', html: '<p>Content</p>' };
149
+ const result = await toolImplementations.ghost_create_post(input);
150
+
151
+ expect(mockCreatePostService).toHaveBeenCalledWith(input);
152
+ expect(result).toEqual(createdPost);
153
+ });
154
+
155
+ it('should handle tags and other optional fields', async () => {
156
+ const createdPost = { id: '1', title: 'Test', tags: ['tech'] };
157
+ mockCreatePostService.mockResolvedValue(createdPost);
158
+
159
+ const input = {
160
+ title: 'Test',
161
+ html: '<p>Content</p>',
162
+ tags: ['tech'],
163
+ status: 'published',
164
+ feature_image: 'https://example.com/image.jpg',
165
+ };
166
+ const result = await toolImplementations.ghost_create_post(input);
167
+
168
+ expect(mockCreatePostService).toHaveBeenCalledWith(input);
169
+ expect(result).toEqual(createdPost);
170
+ });
171
+
172
+ it('should throw wrapped error when service fails', async () => {
173
+ mockCreatePostService.mockRejectedValue(new Error('Database error'));
174
+
175
+ const input = { title: 'Test Post', html: '<p>Content</p>' };
176
+
177
+ await expect(toolImplementations.ghost_create_post(input)).rejects.toThrow(
178
+ 'Failed to create Ghost post: Database error'
179
+ );
180
+ });
181
+ });
182
+
183
+ describe('ghost_upload_image tool', () => {
184
+ beforeEach(() => {
185
+ mockValidateImageUrl.mockReturnValue({
186
+ isValid: true,
187
+ sanitizedUrl: 'https://example.com/image.jpg',
188
+ });
189
+ mockCreateSecureAxiosConfig.mockReturnValue({
190
+ url: 'https://example.com/image.jpg',
191
+ responseType: 'stream',
192
+ });
193
+
194
+ const mockWriter = {
195
+ on: vi.fn((event, cb) => {
196
+ if (event === 'finish') setTimeout(cb, 0);
197
+ return mockWriter;
198
+ }),
199
+ };
200
+ mockCreateWriteStream.mockReturnValue(mockWriter);
201
+
202
+ const mockStream = { pipe: vi.fn().mockReturnValue(mockWriter) };
203
+ mockAxios.mockResolvedValue({ data: mockStream });
204
+
205
+ mockProcessImage.mockResolvedValue('/tmp/processed-image.jpg');
206
+ mockUploadGhostImage.mockResolvedValue({
207
+ url: 'https://ghost.com/content/images/image.jpg',
208
+ });
209
+ });
210
+
211
+ it('should validate image URL for SSRF protection', async () => {
212
+ mockValidateImageUrl.mockReturnValue({
213
+ isValid: false,
214
+ error: 'Private IP address not allowed',
215
+ });
216
+
217
+ const input = { imageUrl: 'http://192.168.1.1/image.jpg' };
218
+
219
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
220
+ 'Invalid image URL: Private IP address not allowed'
221
+ );
222
+ });
223
+
224
+ it('should download, process, and upload image successfully', async () => {
225
+ const input = { imageUrl: 'https://example.com/image.jpg' };
226
+ const result = await toolImplementations.ghost_upload_image(input);
227
+
228
+ expect(mockValidateImageUrl).toHaveBeenCalledWith(input.imageUrl);
229
+ expect(mockAxios).toHaveBeenCalled();
230
+ expect(mockProcessImage).toHaveBeenCalled();
231
+ expect(mockUploadGhostImage).toHaveBeenCalled();
232
+ expect(result.url).toBe('https://ghost.com/content/images/image.jpg');
233
+ });
234
+
235
+ it('should use provided alt text', async () => {
236
+ const input = { imageUrl: 'https://example.com/image.jpg', alt: 'My custom alt' };
237
+ const result = await toolImplementations.ghost_upload_image(input);
238
+
239
+ expect(result.alt).toBe('My custom alt');
240
+ });
241
+
242
+ it('should generate default alt text from filename', async () => {
243
+ const input = { imageUrl: 'https://example.com/beautiful-sunset.jpg' };
244
+ const result = await toolImplementations.ghost_upload_image(input);
245
+
246
+ expect(result.alt).toBeTruthy();
247
+ expect(result.alt).not.toBe('');
248
+ });
249
+
250
+ it('should cleanup temporary files on success', async () => {
251
+ const input = { imageUrl: 'https://example.com/image.jpg' };
252
+ await toolImplementations.ghost_upload_image(input);
253
+
254
+ expect(mockUnlink).toHaveBeenCalled();
255
+ });
256
+
257
+ it('should cleanup temporary files on error', async () => {
258
+ mockUploadGhostImage.mockRejectedValue(new Error('Upload failed'));
259
+
260
+ const input = { imageUrl: 'https://example.com/image.jpg' };
261
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow();
262
+
263
+ expect(mockUnlink).toHaveBeenCalled();
264
+ });
265
+
266
+ it('should handle download errors', async () => {
267
+ mockAxios.mockRejectedValue(new Error('Network error'));
268
+
269
+ const input = { imageUrl: 'https://example.com/image.jpg' };
270
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
271
+ 'Failed to upload image from URL'
272
+ );
273
+ });
274
+
275
+ it('should handle processing errors', async () => {
276
+ mockProcessImage.mockRejectedValue(new Error('Invalid image format'));
277
+
278
+ const input = { imageUrl: 'https://example.com/image.jpg' };
279
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
280
+ 'Failed to upload image from URL'
281
+ );
282
+ });
283
+ });
284
+
285
+ describe('ghost_get_tags tool', () => {
286
+ it('should get all tags without filter', async () => {
287
+ const tags = [
288
+ { id: '1', name: 'Tag1', slug: 'tag1' },
289
+ { id: '2', name: 'Tag2', slug: 'tag2' },
290
+ ];
291
+ mockGetGhostTags.mockResolvedValue(tags);
292
+
293
+ const result = await toolImplementations.ghost_get_tags({});
294
+
295
+ expect(mockGetGhostTags).toHaveBeenCalledWith(undefined);
296
+ expect(result).toEqual(tags);
297
+ });
298
+
299
+ it('should filter tags by name', async () => {
300
+ const tags = [{ id: '1', name: 'Technology', slug: 'technology' }];
301
+ mockGetGhostTags.mockResolvedValue(tags);
302
+
303
+ const result = await toolImplementations.ghost_get_tags({ name: 'Technology' });
304
+
305
+ expect(mockGetGhostTags).toHaveBeenCalledWith('Technology');
306
+ expect(result).toEqual(tags);
307
+ });
308
+
309
+ it('should return empty array when no tags match', async () => {
310
+ mockGetGhostTags.mockResolvedValue([]);
311
+
312
+ const result = await toolImplementations.ghost_get_tags({ name: 'NonExistent' });
313
+
314
+ expect(result).toEqual([]);
315
+ });
316
+
317
+ it('should throw wrapped error when service fails', async () => {
318
+ mockGetGhostTags.mockRejectedValue(new Error('API error'));
319
+
320
+ await expect(toolImplementations.ghost_get_tags({})).rejects.toThrow(
321
+ 'Failed to get Ghost tags: API error'
322
+ );
323
+ });
324
+ });
325
+
326
+ describe('ghost_create_tag tool', () => {
327
+ it('should create tag with name only', async () => {
328
+ const newTag = { id: '1', name: 'NewTag', slug: 'newtag' };
329
+ mockCreateGhostTag.mockResolvedValue(newTag);
330
+
331
+ const input = { name: 'NewTag' };
332
+ const result = await toolImplementations.ghost_create_tag(input);
333
+
334
+ expect(mockCreateGhostTag).toHaveBeenCalledWith(input);
335
+ expect(result).toEqual(newTag);
336
+ });
337
+
338
+ it('should create tag with all fields', async () => {
339
+ const newTag = {
340
+ id: '1',
341
+ name: 'Tech',
342
+ slug: 'technology',
343
+ description: 'Tech articles',
344
+ };
345
+ mockCreateGhostTag.mockResolvedValue(newTag);
346
+
347
+ const input = { name: 'Tech', slug: 'technology', description: 'Tech articles' };
348
+ const result = await toolImplementations.ghost_create_tag(input);
349
+
350
+ expect(mockCreateGhostTag).toHaveBeenCalledWith(input);
351
+ expect(result).toEqual(newTag);
352
+ });
353
+
354
+ it('should throw wrapped error when service fails', async () => {
355
+ mockCreateGhostTag.mockRejectedValue(new Error('Duplicate tag'));
356
+
357
+ await expect(toolImplementations.ghost_create_tag({ name: 'Existing' })).rejects.toThrow(
358
+ 'Failed to create Ghost tag: Duplicate tag'
359
+ );
360
+ });
361
+ });
362
+
363
+ describe('startMCPServer', () => {
364
+ it('should export startMCPServer function', async () => {
365
+ const { startMCPServer } = await import('../mcp_server.js');
366
+ expect(typeof startMCPServer).toBe('function');
367
+ });
368
+ });
369
+
370
+ describe('module exports', () => {
371
+ it('should export mcpServer', async () => {
372
+ const module = await import('../mcp_server.js');
373
+ expect(module.mcpServer).toBeDefined();
374
+ });
375
+
376
+ it('should export startMCPServer', async () => {
377
+ const module = await import('../mcp_server.js');
378
+ expect(module.startMCPServer).toBeDefined();
379
+ });
380
+ });
381
+ });