@jgardner04/ghost-mcp-server 1.13.0 → 1.13.2

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,486 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+
5
+ // Mock the Ghost Admin API with tags support
6
+ vi.mock('@tryghost/admin-api', () => ({
7
+ default: vi.fn(function () {
8
+ return {
9
+ posts: {
10
+ add: vi.fn(),
11
+ browse: vi.fn(),
12
+ read: vi.fn(),
13
+ edit: vi.fn(),
14
+ delete: vi.fn(),
15
+ },
16
+ pages: {
17
+ add: vi.fn(),
18
+ browse: vi.fn(),
19
+ read: vi.fn(),
20
+ edit: vi.fn(),
21
+ delete: vi.fn(),
22
+ },
23
+ tags: {
24
+ add: vi.fn(),
25
+ browse: vi.fn(),
26
+ read: vi.fn(),
27
+ edit: vi.fn(),
28
+ delete: vi.fn(),
29
+ },
30
+ members: {
31
+ add: vi.fn(),
32
+ browse: vi.fn(),
33
+ read: vi.fn(),
34
+ edit: vi.fn(),
35
+ delete: vi.fn(),
36
+ },
37
+ site: {
38
+ read: vi.fn(),
39
+ },
40
+ images: {
41
+ upload: vi.fn(),
42
+ },
43
+ };
44
+ }),
45
+ }));
46
+
47
+ // Mock dotenv
48
+ vi.mock('dotenv', () => mockDotenv());
49
+
50
+ // Mock logger
51
+ vi.mock('../../utils/logger.js', () => ({
52
+ createContextLogger: createMockContextLogger(),
53
+ }));
54
+
55
+ // Mock fs for validateImagePath
56
+ vi.mock('fs/promises', () => ({
57
+ default: {
58
+ access: vi.fn(),
59
+ },
60
+ }));
61
+
62
+ // Import after setting up mocks
63
+ import {
64
+ getTags,
65
+ getTag,
66
+ createTag,
67
+ updateTag,
68
+ deleteTag,
69
+ api,
70
+ ghostCircuitBreaker,
71
+ } from '../ghostServiceImproved.js';
72
+
73
+ describe('ghostServiceImproved - Tags', () => {
74
+ beforeEach(() => {
75
+ // Reset all mocks before each test
76
+ vi.clearAllMocks();
77
+
78
+ // Reset circuit breaker to closed state
79
+ if (ghostCircuitBreaker) {
80
+ ghostCircuitBreaker.state = 'CLOSED';
81
+ ghostCircuitBreaker.failureCount = 0;
82
+ ghostCircuitBreaker.lastFailureTime = null;
83
+ ghostCircuitBreaker.nextAttempt = null;
84
+ }
85
+ });
86
+
87
+ describe('getTags', () => {
88
+ it('should return all tags with default options', async () => {
89
+ const mockTags = [
90
+ { id: 'tag-1', name: 'JavaScript', slug: 'javascript' },
91
+ { id: 'tag-2', name: 'TypeScript', slug: 'typescript' },
92
+ ];
93
+
94
+ api.tags.browse.mockResolvedValue(mockTags);
95
+
96
+ const result = await getTags();
97
+
98
+ expect(api.tags.browse).toHaveBeenCalledWith(
99
+ expect.objectContaining({
100
+ limit: 15,
101
+ }),
102
+ expect.any(Object)
103
+ );
104
+ expect(result).toEqual(mockTags);
105
+ });
106
+
107
+ it('should accept custom limit option', async () => {
108
+ const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
109
+
110
+ api.tags.browse.mockResolvedValue(mockTags);
111
+
112
+ await getTags({ limit: 50 });
113
+
114
+ expect(api.tags.browse).toHaveBeenCalledWith(
115
+ expect.objectContaining({
116
+ limit: 50,
117
+ }),
118
+ expect.any(Object)
119
+ );
120
+ });
121
+
122
+ it('should accept pagination options (page)', async () => {
123
+ const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
124
+
125
+ api.tags.browse.mockResolvedValue(mockTags);
126
+
127
+ await getTags({ limit: 20, page: 2 });
128
+
129
+ expect(api.tags.browse).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ limit: 20,
132
+ page: 2,
133
+ }),
134
+ expect.any(Object)
135
+ );
136
+ });
137
+
138
+ it('should accept filter options', async () => {
139
+ const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
140
+
141
+ api.tags.browse.mockResolvedValue(mockTags);
142
+
143
+ await getTags({ filter: "name:'JavaScript'" });
144
+
145
+ expect(api.tags.browse).toHaveBeenCalledWith(
146
+ expect.objectContaining({
147
+ limit: 15,
148
+ filter: "name:'JavaScript'",
149
+ }),
150
+ expect.any(Object)
151
+ );
152
+ });
153
+
154
+ it('should accept order options', async () => {
155
+ const mockTags = [
156
+ { id: 'tag-1', name: 'JavaScript', slug: 'javascript' },
157
+ { id: 'tag-2', name: 'TypeScript', slug: 'typescript' },
158
+ ];
159
+
160
+ api.tags.browse.mockResolvedValue(mockTags);
161
+
162
+ await getTags({ order: 'name asc' });
163
+
164
+ expect(api.tags.browse).toHaveBeenCalledWith(
165
+ expect.objectContaining({
166
+ limit: 15,
167
+ order: 'name asc',
168
+ }),
169
+ expect.any(Object)
170
+ );
171
+ });
172
+
173
+ it('should accept include options', async () => {
174
+ const mockTags = [
175
+ { id: 'tag-1', name: 'JavaScript', slug: 'javascript', count: { posts: 10 } },
176
+ ];
177
+
178
+ api.tags.browse.mockResolvedValue(mockTags);
179
+
180
+ await getTags({ include: 'count.posts' });
181
+
182
+ expect(api.tags.browse).toHaveBeenCalledWith(
183
+ expect.objectContaining({
184
+ limit: 15,
185
+ include: 'count.posts',
186
+ }),
187
+ expect.any(Object)
188
+ );
189
+ });
190
+
191
+ it('should accept multiple options together', async () => {
192
+ const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
193
+
194
+ api.tags.browse.mockResolvedValue(mockTags);
195
+
196
+ await getTags({
197
+ limit: 25,
198
+ page: 3,
199
+ filter: 'visibility:public',
200
+ order: 'slug desc',
201
+ include: 'count.posts',
202
+ });
203
+
204
+ expect(api.tags.browse).toHaveBeenCalledWith(
205
+ expect.objectContaining({
206
+ limit: 25,
207
+ page: 3,
208
+ filter: 'visibility:public',
209
+ order: 'slug desc',
210
+ include: 'count.posts',
211
+ }),
212
+ expect.any(Object)
213
+ );
214
+ });
215
+
216
+ it('should return empty array when no tags found', async () => {
217
+ api.tags.browse.mockResolvedValue([]);
218
+
219
+ const result = await getTags();
220
+
221
+ expect(result).toEqual([]);
222
+ });
223
+
224
+ it('should return empty array when API returns null', async () => {
225
+ api.tags.browse.mockResolvedValue(null);
226
+
227
+ const result = await getTags();
228
+
229
+ expect(result).toEqual([]);
230
+ });
231
+
232
+ it('should handle Ghost API errors', async () => {
233
+ const apiError = new Error('Ghost API Error');
234
+ api.tags.browse.mockRejectedValue(apiError);
235
+
236
+ // Errors are wrapped by handleApiRequest with "External service error: Ghost API"
237
+ await expect(getTags()).rejects.toThrow(/External service error: Ghost API/);
238
+ });
239
+
240
+ it('should handle network errors with retry logic', async () => {
241
+ const networkError = new Error('Network timeout');
242
+ api.tags.browse.mockRejectedValue(networkError);
243
+
244
+ // Network errors are wrapped by handleApiRequest
245
+ await expect(getTags()).rejects.toThrow(/External service error: Ghost API/);
246
+ });
247
+
248
+ it('should reject requests when circuit breaker is open', async () => {
249
+ // Force circuit breaker to open state
250
+ ghostCircuitBreaker.state = 'OPEN';
251
+ ghostCircuitBreaker.nextAttempt = Date.now() + 60000;
252
+
253
+ await expect(getTags()).rejects.toThrow(/circuit.*open/i);
254
+ });
255
+
256
+ it('should succeed when circuit breaker is closed', async () => {
257
+ const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
258
+
259
+ // Ensure circuit breaker is closed
260
+ ghostCircuitBreaker.state = 'CLOSED';
261
+ api.tags.browse.mockResolvedValue(mockTags);
262
+
263
+ const result = await getTags();
264
+
265
+ expect(result).toEqual(mockTags);
266
+ expect(api.tags.browse).toHaveBeenCalled();
267
+ });
268
+ });
269
+
270
+ describe('getTag', () => {
271
+ it('should get tag by ID', async () => {
272
+ const mockTag = {
273
+ id: 'tag-1',
274
+ name: 'JavaScript',
275
+ slug: 'javascript',
276
+ };
277
+
278
+ api.tags.read.mockResolvedValue(mockTag);
279
+
280
+ const result = await getTag('tag-1');
281
+
282
+ expect(api.tags.read).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({}));
283
+ expect(result).toEqual(mockTag);
284
+ });
285
+
286
+ it('should throw validation error for missing tag ID', async () => {
287
+ await expect(getTag(null)).rejects.toThrow('Tag ID is required');
288
+ });
289
+
290
+ it('should throw not found error when tag does not exist', async () => {
291
+ api.tags.read.mockRejectedValue({
292
+ response: { status: 404 },
293
+ message: 'Tag not found',
294
+ });
295
+
296
+ await expect(getTag('non-existent')).rejects.toThrow();
297
+ });
298
+ });
299
+
300
+ describe('createTag', () => {
301
+ it('should create a tag with required name', async () => {
302
+ const tagData = {
303
+ name: 'JavaScript',
304
+ };
305
+
306
+ const mockCreatedTag = {
307
+ id: 'tag-1',
308
+ name: 'JavaScript',
309
+ slug: 'javascript',
310
+ };
311
+
312
+ api.tags.add.mockResolvedValue(mockCreatedTag);
313
+
314
+ const result = await createTag(tagData);
315
+
316
+ expect(api.tags.add).toHaveBeenCalled();
317
+ expect(result).toEqual(mockCreatedTag);
318
+ });
319
+
320
+ it('should throw validation error for missing tag name', async () => {
321
+ await expect(createTag({})).rejects.toThrow('Tag validation failed');
322
+ });
323
+
324
+ it('should throw validation error for empty tag name', async () => {
325
+ await expect(createTag({ name: ' ' })).rejects.toThrow('Tag validation failed');
326
+ });
327
+
328
+ it('should throw validation error for invalid slug format', async () => {
329
+ await expect(createTag({ name: 'Test', slug: 'INVALID_SLUG!' })).rejects.toThrow(
330
+ 'Tag validation failed'
331
+ );
332
+ });
333
+
334
+ it('should accept valid slug with lowercase letters, numbers, and hyphens', async () => {
335
+ const tagData = {
336
+ name: 'Test Tag',
337
+ slug: 'valid-slug-123',
338
+ };
339
+
340
+ const mockCreatedTag = {
341
+ id: 'tag-1',
342
+ name: 'Test Tag',
343
+ slug: 'valid-slug-123',
344
+ };
345
+
346
+ api.tags.add.mockResolvedValue(mockCreatedTag);
347
+
348
+ const result = await createTag(tagData);
349
+
350
+ expect(result).toEqual(mockCreatedTag);
351
+ });
352
+
353
+ it('should auto-generate slug if not provided', async () => {
354
+ const tagData = {
355
+ name: 'JavaScript & TypeScript',
356
+ };
357
+
358
+ const mockCreatedTag = {
359
+ id: 'tag-1',
360
+ name: 'JavaScript & TypeScript',
361
+ slug: 'javascript-typescript',
362
+ };
363
+
364
+ api.tags.add.mockResolvedValue(mockCreatedTag);
365
+
366
+ await createTag(tagData);
367
+
368
+ // Verify that slug is auto-generated
369
+ expect(api.tags.add).toHaveBeenCalledWith(
370
+ expect.objectContaining({
371
+ name: 'JavaScript & TypeScript',
372
+ slug: expect.stringMatching(/javascript-typescript/),
373
+ }),
374
+ expect.any(Object)
375
+ );
376
+ });
377
+
378
+ it('should handle duplicate tag errors gracefully', async () => {
379
+ const tagData = {
380
+ name: 'JavaScript',
381
+ };
382
+
383
+ // First call fails with duplicate error
384
+ api.tags.add.mockRejectedValue({
385
+ response: { status: 422 },
386
+ message: 'Tag already exists',
387
+ });
388
+
389
+ // getTags returns existing tag when called with name filter
390
+ api.tags.browse.mockResolvedValue([{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }]);
391
+
392
+ const result = await createTag(tagData);
393
+
394
+ // Verify getTags was called with correct filter for duplicate lookup
395
+ expect(api.tags.browse).toHaveBeenCalledWith(
396
+ expect.objectContaining({
397
+ filter: "name:'JavaScript'",
398
+ }),
399
+ expect.any(Object)
400
+ );
401
+ expect(result).toEqual({ id: 'tag-1', name: 'JavaScript', slug: 'javascript' });
402
+ });
403
+ });
404
+
405
+ describe('updateTag', () => {
406
+ it('should send only update fields, not the full existing tag', async () => {
407
+ const tagId = 'tag-1';
408
+ const updateData = {
409
+ name: 'Updated JavaScript',
410
+ description: 'Updated description',
411
+ };
412
+
413
+ const mockExistingTag = {
414
+ id: tagId,
415
+ name: 'JavaScript',
416
+ slug: 'javascript',
417
+ url: 'https://example.com/tag/javascript',
418
+ visibility: 'public',
419
+ };
420
+
421
+ const mockUpdatedTag = {
422
+ ...mockExistingTag,
423
+ ...updateData,
424
+ };
425
+
426
+ api.tags.read.mockResolvedValue(mockExistingTag);
427
+ api.tags.edit.mockResolvedValue(mockUpdatedTag);
428
+
429
+ const result = await updateTag(tagId, updateData);
430
+
431
+ expect(api.tags.read).toHaveBeenCalled();
432
+ // Should send ONLY updateData, NOT the full existing tag
433
+ expect(api.tags.edit).toHaveBeenCalledWith(
434
+ { name: 'Updated JavaScript', description: 'Updated description' },
435
+ { id: tagId }
436
+ );
437
+ // Verify read-only fields are NOT sent
438
+ const editCallData = api.tags.edit.mock.calls[0][0];
439
+ expect(editCallData).not.toHaveProperty('url');
440
+ expect(editCallData).not.toHaveProperty('visibility');
441
+ expect(editCallData).not.toHaveProperty('slug');
442
+ expect(result).toEqual(mockUpdatedTag);
443
+ });
444
+
445
+ it('should throw validation error for missing tag ID', async () => {
446
+ await expect(updateTag(null, { name: 'Test' })).rejects.toThrow(
447
+ 'Tag ID is required for update'
448
+ );
449
+ });
450
+
451
+ it('should throw not found error if tag does not exist', async () => {
452
+ api.tags.read.mockRejectedValue({
453
+ response: { status: 404 },
454
+ message: 'Tag not found',
455
+ });
456
+
457
+ await expect(updateTag('non-existent', { name: 'Test' })).rejects.toThrow();
458
+ });
459
+ });
460
+
461
+ describe('deleteTag', () => {
462
+ it('should delete tag with valid ID', async () => {
463
+ const tagId = 'tag-1';
464
+
465
+ api.tags.delete.mockResolvedValue({ deleted: true });
466
+
467
+ const result = await deleteTag(tagId);
468
+
469
+ expect(api.tags.delete).toHaveBeenCalledWith(tagId, expect.any(Object));
470
+ expect(result).toEqual({ deleted: true });
471
+ });
472
+
473
+ it('should throw validation error for missing tag ID', async () => {
474
+ await expect(deleteTag(null)).rejects.toThrow('Tag ID is required for deletion');
475
+ });
476
+
477
+ it('should throw not found error if tag does not exist', async () => {
478
+ api.tags.delete.mockRejectedValue({
479
+ response: { status: 404 },
480
+ message: 'Tag not found',
481
+ });
482
+
483
+ await expect(deleteTag('non-existent')).rejects.toThrow();
484
+ });
485
+ });
486
+ });
@@ -300,12 +300,15 @@ describe('ghostServiceImproved - Tiers', () => {
300
300
  });
301
301
 
302
302
  describe('updateTier', () => {
303
- it('should update a tier', async () => {
303
+ it('should send only update fields and updated_at, not the full existing tier', async () => {
304
304
  const existingTier = {
305
305
  id: 'tier-1',
306
+ slug: 'premium',
306
307
  name: 'Premium',
307
308
  currency: 'USD',
308
309
  monthly_price: 999,
310
+ type: 'paid',
311
+ active: true,
309
312
  updated_at: '2024-01-01T00:00:00.000Z',
310
313
  };
311
314
 
@@ -328,15 +331,16 @@ describe('ghostServiceImproved - Tiers', () => {
328
331
  expect.objectContaining({ id: 'tier-1' }),
329
332
  expect.objectContaining({ id: 'tier-1' })
330
333
  );
334
+ // Should send ONLY updateData + updated_at, NOT the full existing tier
331
335
  expect(api.tiers.edit).toHaveBeenCalledWith(
332
- expect.objectContaining({
333
- ...existingTier,
334
- ...updateData,
335
- }),
336
- expect.objectContaining({
337
- id: 'tier-1',
338
- })
336
+ { name: 'Premium Plus', monthly_price: 1299, updated_at: '2024-01-01T00:00:00.000Z' },
337
+ expect.objectContaining({ id: 'tier-1' })
339
338
  );
339
+ // Verify read-only fields are NOT sent
340
+ const editCallData = api.tiers.edit.mock.calls[0][0];
341
+ expect(editCallData).not.toHaveProperty('slug');
342
+ expect(editCallData).not.toHaveProperty('type');
343
+ expect(editCallData).not.toHaveProperty('active');
340
344
  expect(result).toEqual(mockUpdatedTier);
341
345
  });
342
346
 
@@ -180,27 +180,30 @@ const createTag = async (tagData) => {
180
180
  };
181
181
 
182
182
  /**
183
- * Retrieves tags from Ghost, optionally filtering by name.
184
- * @param {string} [name] - Optional tag name to filter by.
183
+ * Retrieves tags from Ghost with optional filtering.
184
+ * @param {object} [options={}] - Query options for filtering and pagination.
185
+ * @param {number} [options.limit] - Maximum number of tags to return (default: 15).
186
+ * @param {string} [options.filter] - NQL filter string for advanced filtering.
187
+ * @param {string} [options.order] - Order string for sorting results.
188
+ * @param {string} [options.include] - Include string for related data.
185
189
  * @returns {Promise<Array<object>>} An array of tag objects.
186
190
  */
187
- const getTags = async (name) => {
188
- const options = {
189
- limit: 'all', // Get all tags
190
- };
191
-
192
- // Safely construct filter to prevent injection
193
- if (name) {
194
- // Additional validation: only allow alphanumeric, spaces, hyphens, underscores
195
- if (!/^[a-zA-Z0-9\s\-_]+$/.test(name)) {
196
- throw new Error('Tag name contains invalid characters');
197
- }
198
- // Escape single quotes and backslashes to prevent injection
199
- const safeName = name.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
200
- options.filter = `name:'${safeName}'`;
191
+ const getTags = async (options = {}) => {
192
+ try {
193
+ const tags = await handleApiRequest(
194
+ 'tags',
195
+ 'browse',
196
+ {},
197
+ {
198
+ limit: 15,
199
+ ...options,
200
+ }
201
+ );
202
+ return tags || [];
203
+ } catch (error) {
204
+ logger.error('Failed to get tags', { error: error.message });
205
+ throw error;
201
206
  }
202
-
203
- return handleApiRequest('tags', 'browse', {}, options);
204
207
  };
205
208
 
206
209
  // Add other content management functions here (createTag, etc.)