@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.
- package/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/mockGhostApi.js +36 -0
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +69 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/mcp_server.js +393 -1143
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostServiceImproved.members.test.js +21 -60
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +58 -65
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -51
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +65 -52
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +24 -64
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +6 -53
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostServiceImproved.js +76 -949
- package/src/services/images.js +27 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
|
@@ -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
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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 () => {
|