@jgardner04/ghost-mcp-server 1.13.3 → 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
+ });
@@ -1,48 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
3
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
4
5
 
5
- // Mock the Ghost Admin API with members 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
- }));
6
+ // Mock the Ghost Admin API using shared mock factory
7
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
46
8
 
47
9
  // Mock dotenv
48
10
  vi.mock('dotenv', () => mockDotenv());
@@ -69,6 +31,7 @@ import {
69
31
  searchMembers,
70
32
  api,
71
33
  } from '../ghostServiceImproved.js';
34
+ import { GhostAPIError, NotFoundError } from '../../errors/index.js';
72
35
 
73
36
  describe('ghostServiceImproved - Members', () => {
74
37
  beforeEach(() => {
@@ -214,21 +177,19 @@ describe('ghostServiceImproved - Members', () => {
214
177
  });
215
178
 
216
179
  it('should throw validation error for missing member ID', async () => {
217
- await expect(updateMember(null, { name: 'Test' })).rejects.toThrow(
218
- 'Member ID is required for update'
219
- );
180
+ await expect(updateMember(null, { name: 'Test' })).rejects.toThrow('Member ID is required');
220
181
  });
221
182
 
222
183
  // NOTE: Input validation tests (invalid email in update) have been moved to
223
184
  // MCP layer tests. The service layer now relies on Zod schema validation.
224
185
 
225
186
  it('should throw not found error if member does not exist', async () => {
226
- api.members.read.mockRejectedValue({
227
- response: { status: 404 },
228
- message: 'Member not found',
229
- });
187
+ const error404 = new GhostAPIError('members.read', 'Member not found', 404);
188
+ api.members.read.mockRejectedValue(error404);
230
189
 
231
- await expect(updateMember('non-existent', { name: 'Test' })).rejects.toThrow();
190
+ const rejection = updateMember('non-existent', { name: 'Test' });
191
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
192
+ await expect(rejection).rejects.toThrow('Member not found');
232
193
  });
233
194
  });
234
195
 
@@ -245,16 +206,16 @@ describe('ghostServiceImproved - Members', () => {
245
206
  });
246
207
 
247
208
  it('should throw validation error for missing member ID', async () => {
248
- await expect(deleteMember(null)).rejects.toThrow('Member ID is required for deletion');
209
+ await expect(deleteMember(null)).rejects.toThrow('Member ID is required');
249
210
  });
250
211
 
251
212
  it('should throw not found error if member does not exist', async () => {
252
- api.members.delete.mockRejectedValue({
253
- response: { status: 404 },
254
- message: 'Member not found',
255
- });
213
+ const error404 = new GhostAPIError('members.delete', 'Member not found', 404);
214
+ api.members.delete.mockRejectedValue(error404);
256
215
 
257
- await expect(deleteMember('non-existent')).rejects.toThrow();
216
+ const rejection = deleteMember('non-existent');
217
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
218
+ await expect(rejection).rejects.toThrow('Member not found');
258
219
  });
259
220
  });
260
221
 
@@ -396,12 +357,12 @@ describe('ghostServiceImproved - Members', () => {
396
357
  // moved to MCP layer tests. The service layer now relies on Zod schema validation.
397
358
 
398
359
  it('should throw not found error when member not found by ID', async () => {
399
- api.members.read.mockRejectedValue({
400
- response: { status: 404 },
401
- message: 'Member not found',
402
- });
360
+ const error404 = new GhostAPIError('members.read', 'Member not found', 404);
361
+ api.members.read.mockRejectedValue(error404);
403
362
 
404
- await expect(getMember({ id: 'non-existent' })).rejects.toThrow();
363
+ const rejection = getMember({ id: 'non-existent' });
364
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
365
+ await expect(rejection).rejects.toThrow('Member not found');
405
366
  });
406
367
 
407
368
  it('should throw not found error when member not found by email', async () => {