@jgardner04/ghost-mcp-server 1.13.4 → 1.13.5

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,468 @@
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
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
5
+
6
+ // Mock the Ghost Admin API using shared mock factory
7
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
8
+
9
+ // Mock dotenv
10
+ vi.mock('dotenv', () => mockDotenv());
11
+
12
+ // Mock logger
13
+ vi.mock('../../utils/logger.js', () => ({
14
+ createContextLogger: createMockContextLogger(),
15
+ }));
16
+
17
+ // Mock fs for validators
18
+ vi.mock('fs/promises', () => ({
19
+ default: {
20
+ access: vi.fn(),
21
+ },
22
+ }));
23
+
24
+ import { createResourceService } from '../createResourceService.js';
25
+ import { api, ghostCircuitBreaker } from '../ghostApiClient.js';
26
+ import { GhostAPIError, ValidationError, NotFoundError } from '../../errors/index.js';
27
+
28
+ describe('createResourceService', () => {
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ // Reset circuit breaker state between tests so error tests don't trip it open
32
+ ghostCircuitBreaker.state = 'CLOSED';
33
+ ghostCircuitBreaker.failureCount = 0;
34
+ ghostCircuitBreaker.lastFailureTime = null;
35
+ ghostCircuitBreaker.nextAttempt = null;
36
+ });
37
+
38
+ describe('create', () => {
39
+ it('should create a resource via handleApiRequest', async () => {
40
+ const service = createResourceService({
41
+ resource: 'posts',
42
+ label: 'Post',
43
+ });
44
+
45
+ const data = { title: 'Test Post', html: '<p>Hello</p>' };
46
+ const expected = { id: '1', ...data };
47
+ api.posts.add.mockResolvedValue(expected);
48
+
49
+ const result = await service.create(data);
50
+
51
+ expect(result).toEqual(expected);
52
+ expect(api.posts.add).toHaveBeenCalledWith(data, {});
53
+ });
54
+
55
+ it('should merge createDefaults into data', async () => {
56
+ const service = createResourceService({
57
+ resource: 'posts',
58
+ label: 'Post',
59
+ createDefaults: { status: 'draft' },
60
+ });
61
+
62
+ const data = { title: 'Test Post', html: '<p>Hello</p>' };
63
+ api.posts.add.mockResolvedValue({ id: '1', ...data, status: 'draft' });
64
+
65
+ await service.create(data);
66
+
67
+ expect(api.posts.add).toHaveBeenCalledWith(
68
+ { status: 'draft', title: 'Test Post', html: '<p>Hello</p>' },
69
+ {}
70
+ );
71
+ });
72
+
73
+ it('should allow data to override createDefaults', async () => {
74
+ const service = createResourceService({
75
+ resource: 'posts',
76
+ label: 'Post',
77
+ createDefaults: { status: 'draft' },
78
+ });
79
+
80
+ const data = { title: 'Test', html: '<p>Hi</p>', status: 'published' };
81
+ api.posts.add.mockResolvedValue({ id: '1', ...data });
82
+
83
+ await service.create(data);
84
+
85
+ expect(api.posts.add).toHaveBeenCalledWith(
86
+ expect.objectContaining({ status: 'published' }),
87
+ {}
88
+ );
89
+ });
90
+
91
+ it('should pass createOptions to the API call', async () => {
92
+ const service = createResourceService({
93
+ resource: 'posts',
94
+ label: 'Post',
95
+ createOptions: { source: 'html' },
96
+ });
97
+
98
+ const data = { title: 'Test' };
99
+ api.posts.add.mockResolvedValue({ id: '1', ...data });
100
+
101
+ await service.create(data);
102
+
103
+ expect(api.posts.add).toHaveBeenCalledWith(data, { source: 'html' });
104
+ });
105
+
106
+ it('should merge caller options with createOptions', async () => {
107
+ const service = createResourceService({
108
+ resource: 'posts',
109
+ label: 'Post',
110
+ createOptions: { source: 'html' },
111
+ });
112
+
113
+ const data = { title: 'Test' };
114
+ api.posts.add.mockResolvedValue({ id: '1', ...data });
115
+
116
+ await service.create(data, { formats: 'mobiledoc' });
117
+
118
+ expect(api.posts.add).toHaveBeenCalledWith(data, { source: 'html', formats: 'mobiledoc' });
119
+ });
120
+
121
+ it('should call validateCreate before creating', async () => {
122
+ const validateCreate = vi.fn();
123
+ const service = createResourceService({
124
+ resource: 'posts',
125
+ label: 'Post',
126
+ validateCreate,
127
+ });
128
+
129
+ const data = { title: 'Test' };
130
+ api.posts.add.mockResolvedValue({ id: '1', ...data });
131
+
132
+ await service.create(data);
133
+
134
+ expect(validateCreate).toHaveBeenCalledWith(data);
135
+ expect(validateCreate).toHaveBeenCalledBefore(api.posts.add);
136
+ });
137
+
138
+ it('should support async validateCreate', async () => {
139
+ const validateCreate = vi.fn().mockResolvedValue(undefined);
140
+ const service = createResourceService({
141
+ resource: 'posts',
142
+ label: 'Post',
143
+ validateCreate,
144
+ });
145
+
146
+ const data = { title: 'Test' };
147
+ api.posts.add.mockResolvedValue({ id: '1', ...data });
148
+
149
+ await service.create(data);
150
+
151
+ expect(validateCreate).toHaveBeenCalledWith(data);
152
+ expect(api.posts.add).toHaveBeenCalled();
153
+ });
154
+
155
+ it('should not call API if validateCreate throws', async () => {
156
+ const validateCreate = vi.fn(() => {
157
+ throw new ValidationError('Invalid data');
158
+ });
159
+ const service = createResourceService({
160
+ resource: 'posts',
161
+ label: 'Post',
162
+ validateCreate,
163
+ });
164
+
165
+ await expect(service.create({ title: '' })).rejects.toThrow(ValidationError);
166
+ expect(api.posts.add).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('should convert 422 errors to ValidationError', async () => {
170
+ const service = createResourceService({
171
+ resource: 'posts',
172
+ label: 'Post',
173
+ });
174
+
175
+ const ghostError = new Error('Validation failed');
176
+ ghostError.response = { status: 422 };
177
+ api.posts.add.mockRejectedValue(ghostError);
178
+
179
+ await expect(service.create({ title: 'Test' })).rejects.toThrow(ValidationError);
180
+ });
181
+
182
+ it('should re-throw non-422 errors', async () => {
183
+ const service = createResourceService({
184
+ resource: 'posts',
185
+ label: 'Post',
186
+ });
187
+
188
+ const error = new Error('Server Error');
189
+ error.response = { status: 500 };
190
+ api.posts.add.mockRejectedValue(error);
191
+
192
+ await expect(service.create({ title: 'Test' })).rejects.toThrow(GhostAPIError);
193
+ });
194
+ });
195
+
196
+ describe('update', () => {
197
+ it('should update a resource with OCC', async () => {
198
+ const service = createResourceService({
199
+ resource: 'posts',
200
+ label: 'Post',
201
+ });
202
+
203
+ const existing = { id: 'post-1', title: 'Old', updated_at: '2024-01-01T00:00:00.000Z' };
204
+ const updated = { ...existing, title: 'New' };
205
+
206
+ api.posts.read.mockResolvedValue(existing);
207
+ api.posts.edit.mockResolvedValue(updated);
208
+
209
+ const result = await service.update('post-1', { title: 'New' });
210
+
211
+ expect(result).toEqual(updated);
212
+ expect(api.posts.read).toHaveBeenCalledWith({}, { id: 'post-1' });
213
+ expect(api.posts.edit).toHaveBeenCalledWith(
214
+ { id: 'post-1', title: 'New', updated_at: '2024-01-01T00:00:00.000Z' },
215
+ {}
216
+ );
217
+ });
218
+
219
+ it('should throw ValidationError if ID is missing', async () => {
220
+ const service = createResourceService({
221
+ resource: 'posts',
222
+ label: 'Post',
223
+ });
224
+
225
+ await expect(service.update(undefined, { title: 'New' })).rejects.toThrow(ValidationError);
226
+ await expect(service.update('', { title: 'New' })).rejects.toThrow('Post ID is required');
227
+ });
228
+
229
+ it('should call validateUpdate before updating', async () => {
230
+ const validateUpdate = vi.fn();
231
+ const service = createResourceService({
232
+ resource: 'posts',
233
+ label: 'Post',
234
+ validateUpdate,
235
+ });
236
+
237
+ const existing = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
238
+ api.posts.read.mockResolvedValue(existing);
239
+ api.posts.edit.mockResolvedValue(existing);
240
+
241
+ await service.update('post-1', { title: 'New' });
242
+
243
+ expect(validateUpdate).toHaveBeenCalledWith('post-1', { title: 'New' });
244
+ });
245
+
246
+ it('should support async validateUpdate', async () => {
247
+ const validateUpdate = vi.fn().mockResolvedValue(undefined);
248
+ const service = createResourceService({
249
+ resource: 'posts',
250
+ label: 'Post',
251
+ validateUpdate,
252
+ });
253
+
254
+ const existing = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
255
+ api.posts.read.mockResolvedValue(existing);
256
+ api.posts.edit.mockResolvedValue(existing);
257
+
258
+ await service.update('post-1', { title: 'New' });
259
+
260
+ expect(validateUpdate).toHaveBeenCalled();
261
+ });
262
+
263
+ it('should catch 422 on update when catch422OnUpdate is true', async () => {
264
+ const service = createResourceService({
265
+ resource: 'newsletters',
266
+ label: 'Newsletter',
267
+ catch422OnUpdate: true,
268
+ });
269
+
270
+ const existing = { id: 'nl-1', updated_at: '2024-01-01T00:00:00.000Z' };
271
+ api.newsletters.read.mockResolvedValue(existing);
272
+
273
+ const ghostError = new Error('Name already exists');
274
+ ghostError.response = { status: 422 };
275
+ api.newsletters.edit.mockRejectedValue(ghostError);
276
+
277
+ try {
278
+ await service.update('nl-1', { name: 'Dupe' });
279
+ expect.fail('Expected ValidationError to be thrown');
280
+ } catch (error) {
281
+ expect(error).toBeInstanceOf(ValidationError);
282
+ expect(error.message).toBe('Newsletter update failed');
283
+ }
284
+ });
285
+
286
+ it('should not catch 422 on update when catch422OnUpdate is false', async () => {
287
+ const service = createResourceService({
288
+ resource: 'posts',
289
+ label: 'Post',
290
+ catch422OnUpdate: false,
291
+ });
292
+
293
+ const existing = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
294
+ api.posts.read.mockResolvedValue(existing);
295
+
296
+ const ghostError = new Error('Something');
297
+ ghostError.response = { status: 422 };
298
+ api.posts.edit.mockRejectedValue(ghostError);
299
+
300
+ await expect(service.update('post-1', { title: 'New' })).rejects.toThrow(GhostAPIError);
301
+ });
302
+ });
303
+
304
+ describe('remove', () => {
305
+ it('should delete a resource by ID', async () => {
306
+ const service = createResourceService({
307
+ resource: 'posts',
308
+ label: 'Post',
309
+ });
310
+
311
+ api.posts.delete.mockResolvedValue({ id: 'post-1' });
312
+
313
+ const result = await service.remove('post-1');
314
+
315
+ expect(result).toEqual({ id: 'post-1' });
316
+ expect(api.posts.delete).toHaveBeenCalledWith('post-1', {});
317
+ });
318
+
319
+ it('should throw ValidationError if ID is missing', async () => {
320
+ const service = createResourceService({
321
+ resource: 'posts',
322
+ label: 'Post',
323
+ });
324
+
325
+ await expect(service.remove()).rejects.toThrow(ValidationError);
326
+ await expect(service.remove()).rejects.toThrow('Post ID is required');
327
+ });
328
+
329
+ it('should throw NotFoundError when resource does not exist', async () => {
330
+ const service = createResourceService({
331
+ resource: 'posts',
332
+ label: 'Post',
333
+ });
334
+
335
+ const ghostError = new Error('Not found');
336
+ ghostError.response = { status: 404 };
337
+ api.posts.delete.mockRejectedValue(ghostError);
338
+
339
+ await expect(service.remove('nonexistent')).rejects.toThrow(NotFoundError);
340
+ });
341
+ });
342
+
343
+ describe('getOne', () => {
344
+ it('should retrieve a resource by ID', async () => {
345
+ const service = createResourceService({
346
+ resource: 'posts',
347
+ label: 'Post',
348
+ });
349
+
350
+ const expected = { id: 'post-1', title: 'Test' };
351
+ api.posts.read.mockResolvedValue(expected);
352
+
353
+ const result = await service.getOne('post-1');
354
+
355
+ expect(result).toEqual(expected);
356
+ expect(api.posts.read).toHaveBeenCalledWith({}, { id: 'post-1' });
357
+ });
358
+
359
+ it('should pass options to read', async () => {
360
+ const service = createResourceService({
361
+ resource: 'posts',
362
+ label: 'Post',
363
+ });
364
+
365
+ api.posts.read.mockResolvedValue({ id: 'post-1' });
366
+
367
+ await service.getOne('post-1', { include: 'tags' });
368
+
369
+ expect(api.posts.read).toHaveBeenCalledWith({ include: 'tags' }, { id: 'post-1' });
370
+ });
371
+
372
+ it('should throw ValidationError if ID is missing', async () => {
373
+ const service = createResourceService({
374
+ resource: 'posts',
375
+ label: 'Post',
376
+ });
377
+
378
+ await expect(service.getOne()).rejects.toThrow(ValidationError);
379
+ });
380
+
381
+ it('should throw NotFoundError when resource does not exist', async () => {
382
+ const service = createResourceService({
383
+ resource: 'posts',
384
+ label: 'Post',
385
+ });
386
+
387
+ const ghostError = new Error('Not found');
388
+ ghostError.response = { status: 404 };
389
+ api.posts.read.mockRejectedValue(ghostError);
390
+
391
+ await expect(service.getOne('nonexistent')).rejects.toThrow(NotFoundError);
392
+ });
393
+ });
394
+
395
+ describe('getList', () => {
396
+ it('should list resources with defaults', async () => {
397
+ const service = createResourceService({
398
+ resource: 'posts',
399
+ label: 'Post',
400
+ listDefaults: { limit: 15, include: 'tags,authors' },
401
+ });
402
+
403
+ const expected = [{ id: '1' }, { id: '2' }];
404
+ api.posts.browse.mockResolvedValue(expected);
405
+
406
+ const result = await service.getList();
407
+
408
+ expect(result).toEqual(expected);
409
+ expect(api.posts.browse).toHaveBeenCalledWith({ limit: 15, include: 'tags,authors' }, {});
410
+ });
411
+
412
+ it('should allow overriding defaults', async () => {
413
+ const service = createResourceService({
414
+ resource: 'posts',
415
+ label: 'Post',
416
+ listDefaults: { limit: 15, include: 'tags,authors' },
417
+ });
418
+
419
+ api.posts.browse.mockResolvedValue([]);
420
+
421
+ await service.getList({ limit: 5, filter: 'status:published' });
422
+
423
+ expect(api.posts.browse).toHaveBeenCalledWith(
424
+ { limit: 5, include: 'tags,authors', filter: 'status:published' },
425
+ {}
426
+ );
427
+ });
428
+
429
+ it('should return empty array when API returns null/undefined', async () => {
430
+ const service = createResourceService({
431
+ resource: 'posts',
432
+ label: 'Post',
433
+ });
434
+
435
+ api.posts.browse.mockResolvedValue(null);
436
+
437
+ const result = await service.getList();
438
+
439
+ expect(result).toEqual([]);
440
+ });
441
+
442
+ it('should return empty array when API returns empty array', async () => {
443
+ const service = createResourceService({
444
+ resource: 'posts',
445
+ label: 'Post',
446
+ });
447
+
448
+ api.posts.browse.mockResolvedValue([]);
449
+
450
+ const result = await service.getList();
451
+
452
+ expect(result).toEqual([]);
453
+ });
454
+
455
+ it('should use default limit of 15 when no listDefaults provided', async () => {
456
+ const service = createResourceService({
457
+ resource: 'tags',
458
+ label: 'Tag',
459
+ });
460
+
461
+ api.tags.browse.mockResolvedValue([]);
462
+
463
+ await service.getList();
464
+
465
+ expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15 }, {});
466
+ });
467
+ });
468
+ });
@@ -185,7 +185,6 @@ describe('ghostServiceImproved - Members', () => {
185
185
 
186
186
  it('should throw not found error if member does not exist', async () => {
187
187
  const error404 = new GhostAPIError('members.read', 'Member not found', 404);
188
- error404.response = { status: 404 };
189
188
  api.members.read.mockRejectedValue(error404);
190
189
 
191
190
  const rejection = updateMember('non-existent', { name: 'Test' });
@@ -212,7 +211,6 @@ describe('ghostServiceImproved - Members', () => {
212
211
 
213
212
  it('should throw not found error if member does not exist', async () => {
214
213
  const error404 = new GhostAPIError('members.delete', 'Member not found', 404);
215
- error404.response = { status: 404 };
216
214
  api.members.delete.mockRejectedValue(error404);
217
215
 
218
216
  const rejection = deleteMember('non-existent');
@@ -360,7 +358,6 @@ describe('ghostServiceImproved - Members', () => {
360
358
 
361
359
  it('should throw not found error when member not found by ID', async () => {
362
360
  const error404 = new GhostAPIError('members.read', 'Member not found', 404);
363
- error404.response = { status: 404 };
364
361
  api.members.read.mockRejectedValue(error404);
365
362
 
366
363
  const rejection = getMember({ id: 'non-existent' });
@@ -242,7 +242,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
242
242
 
243
243
  it('should throw ValidationError if ID is missing', async () => {
244
244
  await expect(updateNewsletter()).rejects.toThrow(ValidationError);
245
- await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required for update');
245
+ await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required');
246
246
  expect(api.newsletters.read).not.toHaveBeenCalled();
247
247
  });
248
248
 
@@ -293,7 +293,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
293
293
 
294
294
  it('should throw ValidationError if ID is missing', async () => {
295
295
  await expect(deleteNewsletter()).rejects.toThrow(ValidationError);
296
- await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required for deletion');
296
+ await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required');
297
297
  expect(api.newsletters.delete).not.toHaveBeenCalled();
298
298
  });
299
299
 
@@ -274,7 +274,6 @@ describe('ghostServiceImproved - Pages', () => {
274
274
 
275
275
  it('should handle page not found (404)', async () => {
276
276
  const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
277
- error404.response = { status: 404 };
278
277
  api.pages.read.mockRejectedValue(error404);
279
278
 
280
279
  const rejection = updatePage('nonexistent-id', { title: 'Updated' });
@@ -387,7 +386,6 @@ describe('ghostServiceImproved - Pages', () => {
387
386
 
388
387
  it('should handle page not found (404)', async () => {
389
388
  const error404 = new GhostAPIError('pages.delete', 'Page not found', 404);
390
- error404.response = { status: 404 };
391
389
  api.pages.delete.mockRejectedValue(error404);
392
390
 
393
391
  const rejection = deletePage('nonexistent-id');
@@ -437,7 +435,6 @@ describe('ghostServiceImproved - Pages', () => {
437
435
 
438
436
  it('should handle page not found (404)', async () => {
439
437
  const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
440
- error404.response = { status: 404 };
441
438
  api.pages.read.mockRejectedValue(error404);
442
439
 
443
440
  const rejection = getPage('nonexistent-id');
@@ -93,7 +93,6 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
93
93
 
94
94
  it('should handle post not found (404)', async () => {
95
95
  const error404 = new GhostAPIError('posts.read', 'Post not found', 404);
96
- error404.response = { status: 404 };
97
96
  api.posts.read.mockRejectedValue(error404);
98
97
 
99
98
  const rejection = updatePost('nonexistent-id', { title: 'Updated' });
@@ -252,7 +252,6 @@ describe('ghostServiceImproved - Tags', () => {
252
252
 
253
253
  it('should throw not found error when tag does not exist', async () => {
254
254
  const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
255
- error404.response = { status: 404 };
256
255
  api.tags.read.mockRejectedValue(error404);
257
256
 
258
257
  const rejection = getTag('non-existent');
@@ -411,7 +410,6 @@ describe('ghostServiceImproved - Tags', () => {
411
410
 
412
411
  it('should throw not found error if tag does not exist', async () => {
413
412
  const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
414
- error404.response = { status: 404 };
415
413
  api.tags.read.mockRejectedValue(error404);
416
414
 
417
415
  const rejection = updateTag('non-existent', { name: 'Test' });
@@ -438,7 +436,6 @@ describe('ghostServiceImproved - Tags', () => {
438
436
 
439
437
  it('should throw not found error if tag does not exist', async () => {
440
438
  const error404 = new GhostAPIError('tags.delete', 'Tag not found', 404);
441
- error404.response = { status: 404 };
442
439
  api.tags.delete.mockRejectedValue(error404);
443
440
 
444
441
  const rejection = deleteTag('non-existent');
@@ -295,9 +295,7 @@ describe('ghostServiceImproved - Tiers', () => {
295
295
  });
296
296
 
297
297
  it('should throw ValidationError when ID is missing', async () => {
298
- await expect(updateTier('', { name: 'Updated' })).rejects.toThrow(
299
- 'Tier ID is required for update'
300
- );
298
+ await expect(updateTier('', { name: 'Updated' })).rejects.toThrow('Tier ID is required');
301
299
  });
302
300
 
303
301
  it('should throw ValidationError for invalid update data', async () => {
@@ -327,11 +325,11 @@ describe('ghostServiceImproved - Tiers', () => {
327
325
  });
328
326
 
329
327
  it('should throw ValidationError when ID is missing', async () => {
330
- await expect(deleteTier()).rejects.toThrow('Tier ID is required for deletion');
328
+ await expect(deleteTier()).rejects.toThrow('Tier ID is required');
331
329
  });
332
330
 
333
331
  it('should throw ValidationError when ID is empty string', async () => {
334
- await expect(deleteTier('')).rejects.toThrow('Tier ID is required for deletion');
332
+ await expect(deleteTier('')).rejects.toThrow('Tier ID is required');
335
333
  });
336
334
 
337
335
  it('should throw NotFoundError when tier does not exist', async () => {