@jgardner04/ghost-mcp-server 1.13.2 → 1.13.4
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/package.json +5 -13
- package/src/__tests__/helpers/mockGhostApi.js +36 -0
- package/src/__tests__/mcp_server.test.js +204 -117
- package/src/__tests__/mcp_server_pages.test.js +32 -18
- package/src/config/mcp-config.js +1 -1
- package/src/controllers/__tests__/tagController.test.js +12 -8
- package/src/controllers/tagController.js +2 -2
- package/src/errors/__tests__/index.test.js +3 -3
- package/src/errors/index.js +1 -1
- package/src/index.js +1 -1
- package/src/mcp_server.js +35 -31
- package/src/schemas/__tests__/postSchemas.test.js +19 -0
- package/src/schemas/__tests__/tagSchemas.test.js +1 -1
- package/src/schemas/common.js +2 -2
- package/src/schemas/memberSchemas.js +20 -8
- package/src/schemas/newsletterSchemas.js +10 -10
- package/src/schemas/pageSchemas.js +16 -11
- package/src/schemas/postSchemas.js +22 -15
- package/src/schemas/tagSchemas.js +12 -7
- package/src/schemas/tierSchemas.js +17 -8
- package/src/services/__tests__/ghostServiceImproved.members.test.js +31 -62
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +66 -69
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -48
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +69 -55
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +29 -66
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -62
- package/src/services/__tests__/memberService.test.js +0 -28
- package/src/services/__tests__/tierService.test.js +0 -28
- package/src/services/ghostServiceImproved.js +117 -299
- package/src/services/imageProcessingService.js +1 -1
- package/src/services/memberService.js +0 -13
- package/src/services/tierService.js +0 -13
- package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
- package/src/utils/nqlSanitizer.js +11 -0
|
@@ -1,41 +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
|
-
site: {
|
|
31
|
-
read: vi.fn(),
|
|
32
|
-
},
|
|
33
|
-
images: {
|
|
34
|
-
upload: vi.fn(),
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
}),
|
|
38
|
-
}));
|
|
6
|
+
// Mock the Ghost Admin API using shared mock factory
|
|
7
|
+
vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
|
|
39
8
|
|
|
40
9
|
// Mock dotenv
|
|
41
10
|
vi.mock('dotenv', () => mockDotenv());
|
|
@@ -64,6 +33,7 @@ import {
|
|
|
64
33
|
validators,
|
|
65
34
|
} from '../ghostServiceImproved.js';
|
|
66
35
|
import { createPageSchema, updatePageSchema } from '../../schemas/pageSchemas.js';
|
|
36
|
+
import { GhostAPIError, NotFoundError, ValidationError } from '../../errors/index.js';
|
|
67
37
|
|
|
68
38
|
describe('ghostServiceImproved - Pages', () => {
|
|
69
39
|
beforeEach(() => {
|
|
@@ -236,13 +206,13 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
236
206
|
});
|
|
237
207
|
|
|
238
208
|
it('should handle Ghost API validation errors (422)', async () => {
|
|
239
|
-
const error422 = new
|
|
209
|
+
const error422 = new GhostAPIError('pages.add', 'Validation failed', 422);
|
|
240
210
|
error422.response = { status: 422 };
|
|
241
211
|
api.pages.add.mockRejectedValue(error422);
|
|
242
212
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
);
|
|
213
|
+
const rejection = createPage({ title: 'Test', html: '<p>Content</p>' });
|
|
214
|
+
await expect(rejection).rejects.toBeInstanceOf(ValidationError);
|
|
215
|
+
await expect(rejection).rejects.toThrow('Page creation failed due to validation errors');
|
|
246
216
|
});
|
|
247
217
|
|
|
248
218
|
it('should NOT include tags in page creation (pages do not support tags)', async () => {
|
|
@@ -292,8 +262,8 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
292
262
|
expect(api.pages.read).toHaveBeenCalledWith({}, { id: pageId });
|
|
293
263
|
// Should send ONLY updateData + updated_at, NOT the full existing page
|
|
294
264
|
expect(api.pages.edit).toHaveBeenCalledWith(
|
|
295
|
-
{ title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
|
|
296
|
-
{
|
|
265
|
+
{ id: pageId, title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
|
|
266
|
+
{}
|
|
297
267
|
);
|
|
298
268
|
// Verify read-only fields are NOT sent
|
|
299
269
|
const editCallData = api.pages.edit.mock.calls[0][0];
|
|
@@ -303,13 +273,13 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
303
273
|
});
|
|
304
274
|
|
|
305
275
|
it('should handle page not found (404)', async () => {
|
|
306
|
-
const error404 = new
|
|
276
|
+
const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
|
|
307
277
|
error404.response = { status: 404 };
|
|
308
278
|
api.pages.read.mockRejectedValue(error404);
|
|
309
279
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
);
|
|
280
|
+
const rejection = updatePage('nonexistent-id', { title: 'Updated' });
|
|
281
|
+
await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
|
|
282
|
+
await expect(rejection).rejects.toThrow('Page not found');
|
|
313
283
|
});
|
|
314
284
|
|
|
315
285
|
it('should preserve updated_at timestamp for conflict resolution', async () => {
|
|
@@ -341,6 +311,61 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
341
311
|
'Page validation failed'
|
|
342
312
|
);
|
|
343
313
|
});
|
|
314
|
+
|
|
315
|
+
it('should reject past published_at when existing page is scheduled (no status in update)', async () => {
|
|
316
|
+
const pageId = 'page-123';
|
|
317
|
+
const existingPage = {
|
|
318
|
+
id: pageId,
|
|
319
|
+
title: 'Scheduled Page',
|
|
320
|
+
status: 'scheduled',
|
|
321
|
+
published_at: new Date(Date.now() + 86400000).toISOString(),
|
|
322
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
323
|
+
};
|
|
324
|
+
const pastDate = new Date(Date.now() - 86400000).toISOString();
|
|
325
|
+
|
|
326
|
+
api.pages.read.mockResolvedValue(existingPage);
|
|
327
|
+
|
|
328
|
+
await expect(updatePage(pageId, { published_at: pastDate })).rejects.toThrow(
|
|
329
|
+
'Page validation failed'
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should allow future published_at when existing page is scheduled (no status in update)', async () => {
|
|
334
|
+
const pageId = 'page-123';
|
|
335
|
+
const futureDate = new Date(Date.now() + 172800000).toISOString();
|
|
336
|
+
const existingPage = {
|
|
337
|
+
id: pageId,
|
|
338
|
+
title: 'Scheduled Page',
|
|
339
|
+
status: 'scheduled',
|
|
340
|
+
published_at: new Date(Date.now() + 86400000).toISOString(),
|
|
341
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
api.pages.read.mockResolvedValue(existingPage);
|
|
345
|
+
api.pages.edit.mockResolvedValue({ ...existingPage, published_at: futureDate });
|
|
346
|
+
|
|
347
|
+
const result = await updatePage(pageId, { published_at: futureDate });
|
|
348
|
+
|
|
349
|
+
expect(result).toBeDefined();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should allow published_at change when existing page is not scheduled', async () => {
|
|
353
|
+
const pageId = 'page-123';
|
|
354
|
+
const pastDate = new Date(Date.now() - 86400000).toISOString();
|
|
355
|
+
const existingPage = {
|
|
356
|
+
id: pageId,
|
|
357
|
+
title: 'Draft Page',
|
|
358
|
+
status: 'draft',
|
|
359
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
api.pages.read.mockResolvedValue(existingPage);
|
|
363
|
+
api.pages.edit.mockResolvedValue({ ...existingPage, published_at: pastDate });
|
|
364
|
+
|
|
365
|
+
const result = await updatePage(pageId, { published_at: pastDate });
|
|
366
|
+
|
|
367
|
+
expect(result).toBeDefined();
|
|
368
|
+
});
|
|
344
369
|
});
|
|
345
370
|
|
|
346
371
|
describe('deletePage', () => {
|
|
@@ -361,11 +386,13 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
361
386
|
});
|
|
362
387
|
|
|
363
388
|
it('should handle page not found (404)', async () => {
|
|
364
|
-
const error404 = new
|
|
389
|
+
const error404 = new GhostAPIError('pages.delete', 'Page not found', 404);
|
|
365
390
|
error404.response = { status: 404 };
|
|
366
391
|
api.pages.delete.mockRejectedValue(error404);
|
|
367
392
|
|
|
368
|
-
|
|
393
|
+
const rejection = deletePage('nonexistent-id');
|
|
394
|
+
await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
|
|
395
|
+
await expect(rejection).rejects.toThrow('Page not found');
|
|
369
396
|
});
|
|
370
397
|
});
|
|
371
398
|
|
|
@@ -409,11 +436,13 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
409
436
|
});
|
|
410
437
|
|
|
411
438
|
it('should handle page not found (404)', async () => {
|
|
412
|
-
const error404 = new
|
|
439
|
+
const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
|
|
413
440
|
error404.response = { status: 404 };
|
|
414
441
|
api.pages.read.mockRejectedValue(error404);
|
|
415
442
|
|
|
416
|
-
|
|
443
|
+
const rejection = getPage('nonexistent-id');
|
|
444
|
+
await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
|
|
445
|
+
await expect(rejection).rejects.toThrow('Page not found');
|
|
417
446
|
});
|
|
418
447
|
});
|
|
419
448
|
|
|
@@ -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());
|
|
@@ -62,6 +24,7 @@ vi.mock('fs/promises', () => ({
|
|
|
62
24
|
// Import after setting up mocks
|
|
63
25
|
import { updatePost, api, validators } from '../ghostServiceImproved.js';
|
|
64
26
|
import { updatePostSchema } from '../../schemas/postSchemas.js';
|
|
27
|
+
import { GhostAPIError, NotFoundError } from '../../errors/index.js';
|
|
65
28
|
|
|
66
29
|
describe('ghostServiceImproved - Posts (updatePost)', () => {
|
|
67
30
|
beforeEach(() => {
|
|
@@ -95,8 +58,8 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
|
|
|
95
58
|
expect(result).toEqual(expectedResult);
|
|
96
59
|
// Should send ONLY updateData + updated_at, NOT the full existing post
|
|
97
60
|
expect(api.posts.edit).toHaveBeenCalledWith(
|
|
98
|
-
{ title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
|
|
99
|
-
{
|
|
61
|
+
{ id: postId, title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
|
|
62
|
+
{}
|
|
100
63
|
);
|
|
101
64
|
// Verify read-only fields are NOT sent
|
|
102
65
|
const editCallData = api.posts.edit.mock.calls[0][0];
|
|
@@ -124,22 +87,18 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
|
|
|
124
87
|
});
|
|
125
88
|
|
|
126
89
|
it('should throw error when post ID is missing', async () => {
|
|
127
|
-
await expect(updatePost(null, { title: 'Updated' })).rejects.toThrow(
|
|
128
|
-
|
|
129
|
-
);
|
|
130
|
-
await expect(updatePost('', { title: 'Updated' })).rejects.toThrow(
|
|
131
|
-
'Post ID is required for update'
|
|
132
|
-
);
|
|
90
|
+
await expect(updatePost(null, { title: 'Updated' })).rejects.toThrow('Post ID is required');
|
|
91
|
+
await expect(updatePost('', { title: 'Updated' })).rejects.toThrow('Post ID is required');
|
|
133
92
|
});
|
|
134
93
|
|
|
135
94
|
it('should handle post not found (404)', async () => {
|
|
136
|
-
const error404 = new
|
|
95
|
+
const error404 = new GhostAPIError('posts.read', 'Post not found', 404);
|
|
137
96
|
error404.response = { status: 404 };
|
|
138
97
|
api.posts.read.mockRejectedValue(error404);
|
|
139
98
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
99
|
+
const rejection = updatePost('nonexistent-id', { title: 'Updated' });
|
|
100
|
+
await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
|
|
101
|
+
await expect(rejection).rejects.toThrow('Post not found');
|
|
143
102
|
});
|
|
144
103
|
|
|
145
104
|
it('should throw ValidationError when updating to scheduled without published_at', async () => {
|
|
@@ -173,10 +132,65 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
|
|
|
173
132
|
|
|
174
133
|
expect(result).toBeDefined();
|
|
175
134
|
expect(api.posts.edit).toHaveBeenCalledWith(
|
|
176
|
-
{ ...updateData, updated_at: existingPost.updated_at },
|
|
177
|
-
{
|
|
135
|
+
{ id: postId, ...updateData, updated_at: existingPost.updated_at },
|
|
136
|
+
{}
|
|
178
137
|
);
|
|
179
138
|
});
|
|
139
|
+
|
|
140
|
+
it('should reject past published_at when existing post is scheduled (no status in update)', async () => {
|
|
141
|
+
const postId = 'post-123';
|
|
142
|
+
const existingPost = {
|
|
143
|
+
id: postId,
|
|
144
|
+
title: 'Scheduled Post',
|
|
145
|
+
status: 'scheduled',
|
|
146
|
+
published_at: new Date(Date.now() + 86400000).toISOString(),
|
|
147
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
148
|
+
};
|
|
149
|
+
const pastDate = new Date(Date.now() - 86400000).toISOString();
|
|
150
|
+
|
|
151
|
+
api.posts.read.mockResolvedValue(existingPost);
|
|
152
|
+
|
|
153
|
+
await expect(updatePost(postId, { published_at: pastDate })).rejects.toThrow(
|
|
154
|
+
'Post validation failed'
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should allow future published_at when existing post is scheduled (no status in update)', async () => {
|
|
159
|
+
const postId = 'post-123';
|
|
160
|
+
const futureDate = new Date(Date.now() + 172800000).toISOString();
|
|
161
|
+
const existingPost = {
|
|
162
|
+
id: postId,
|
|
163
|
+
title: 'Scheduled Post',
|
|
164
|
+
status: 'scheduled',
|
|
165
|
+
published_at: new Date(Date.now() + 86400000).toISOString(),
|
|
166
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
api.posts.read.mockResolvedValue(existingPost);
|
|
170
|
+
api.posts.edit.mockResolvedValue({ ...existingPost, published_at: futureDate });
|
|
171
|
+
|
|
172
|
+
const result = await updatePost(postId, { published_at: futureDate });
|
|
173
|
+
|
|
174
|
+
expect(result).toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should allow published_at change when existing post is not scheduled', async () => {
|
|
178
|
+
const postId = 'post-123';
|
|
179
|
+
const pastDate = new Date(Date.now() - 86400000).toISOString();
|
|
180
|
+
const existingPost = {
|
|
181
|
+
id: postId,
|
|
182
|
+
title: 'Draft Post',
|
|
183
|
+
status: 'draft',
|
|
184
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
api.posts.read.mockResolvedValue(existingPost);
|
|
188
|
+
api.posts.edit.mockResolvedValue({ ...existingPost, published_at: pastDate });
|
|
189
|
+
|
|
190
|
+
const result = await updatePost(postId, { published_at: pastDate });
|
|
191
|
+
|
|
192
|
+
expect(result).toBeDefined();
|
|
193
|
+
});
|
|
180
194
|
});
|
|
181
195
|
|
|
182
196
|
describe('validators.validateScheduledStatus', () => {
|
|
@@ -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
|
api,
|
|
70
32
|
ghostCircuitBreaker,
|
|
71
33
|
} from '../ghostServiceImproved.js';
|
|
34
|
+
import { GhostAPIError, NotFoundError } from '../../errors/index.js';
|
|
72
35
|
|
|
73
36
|
describe('ghostServiceImproved - Tags', () => {
|
|
74
37
|
beforeEach(() => {
|
|
@@ -288,12 +251,13 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
288
251
|
});
|
|
289
252
|
|
|
290
253
|
it('should throw not found error when tag does not exist', async () => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
});
|
|
254
|
+
const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
|
|
255
|
+
error404.response = { status: 404 };
|
|
256
|
+
api.tags.read.mockRejectedValue(error404);
|
|
295
257
|
|
|
296
|
-
|
|
258
|
+
const rejection = getTag('non-existent');
|
|
259
|
+
await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
|
|
260
|
+
await expect(rejection).rejects.toThrow('Tag not found');
|
|
297
261
|
});
|
|
298
262
|
});
|
|
299
263
|
|
|
@@ -381,10 +345,9 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
381
345
|
};
|
|
382
346
|
|
|
383
347
|
// First call fails with duplicate error
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
});
|
|
348
|
+
const error422 = new GhostAPIError('tags.add', 'Tag already exists', 422);
|
|
349
|
+
error422.response = { status: 422, data: { errors: [{ message: 'Tag already exists' }] } };
|
|
350
|
+
api.tags.add.mockRejectedValue(error422);
|
|
388
351
|
|
|
389
352
|
// getTags returns existing tag when called with name filter
|
|
390
353
|
api.tags.browse.mockResolvedValue([{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }]);
|
|
@@ -431,8 +394,8 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
431
394
|
expect(api.tags.read).toHaveBeenCalled();
|
|
432
395
|
// Should send ONLY updateData, NOT the full existing tag
|
|
433
396
|
expect(api.tags.edit).toHaveBeenCalledWith(
|
|
434
|
-
{ name: 'Updated JavaScript', description: 'Updated description' },
|
|
435
|
-
{
|
|
397
|
+
{ id: tagId, name: 'Updated JavaScript', description: 'Updated description' },
|
|
398
|
+
{}
|
|
436
399
|
);
|
|
437
400
|
// Verify read-only fields are NOT sent
|
|
438
401
|
const editCallData = api.tags.edit.mock.calls[0][0];
|
|
@@ -443,18 +406,17 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
443
406
|
});
|
|
444
407
|
|
|
445
408
|
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
|
-
);
|
|
409
|
+
await expect(updateTag(null, { name: 'Test' })).rejects.toThrow('Tag ID is required');
|
|
449
410
|
});
|
|
450
411
|
|
|
451
412
|
it('should throw not found error if tag does not exist', async () => {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
});
|
|
413
|
+
const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
|
|
414
|
+
error404.response = { status: 404 };
|
|
415
|
+
api.tags.read.mockRejectedValue(error404);
|
|
456
416
|
|
|
457
|
-
|
|
417
|
+
const rejection = updateTag('non-existent', { name: 'Test' });
|
|
418
|
+
await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
|
|
419
|
+
await expect(rejection).rejects.toThrow('Tag not found');
|
|
458
420
|
});
|
|
459
421
|
});
|
|
460
422
|
|
|
@@ -471,16 +433,17 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
471
433
|
});
|
|
472
434
|
|
|
473
435
|
it('should throw validation error for missing tag ID', async () => {
|
|
474
|
-
await expect(deleteTag(null)).rejects.toThrow('Tag ID is required
|
|
436
|
+
await expect(deleteTag(null)).rejects.toThrow('Tag ID is required');
|
|
475
437
|
});
|
|
476
438
|
|
|
477
439
|
it('should throw not found error if tag does not exist', async () => {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
});
|
|
440
|
+
const error404 = new GhostAPIError('tags.delete', 'Tag not found', 404);
|
|
441
|
+
error404.response = { status: 404 };
|
|
442
|
+
api.tags.delete.mockRejectedValue(error404);
|
|
482
443
|
|
|
483
|
-
|
|
444
|
+
const rejection = deleteTag('non-existent');
|
|
445
|
+
await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
|
|
446
|
+
await expect(rejection).rejects.toThrow('Tag not found');
|
|
484
447
|
});
|
|
485
448
|
});
|
|
486
449
|
});
|
|
@@ -1,55 +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
|
-
tiers: {
|
|
38
|
-
add: vi.fn(),
|
|
39
|
-
browse: vi.fn(),
|
|
40
|
-
read: vi.fn(),
|
|
41
|
-
edit: vi.fn(),
|
|
42
|
-
delete: vi.fn(),
|
|
43
|
-
},
|
|
44
|
-
site: {
|
|
45
|
-
read: vi.fn(),
|
|
46
|
-
},
|
|
47
|
-
images: {
|
|
48
|
-
upload: vi.fn(),
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}),
|
|
52
|
-
}));
|
|
6
|
+
// Mock the Ghost Admin API using shared mock factory
|
|
7
|
+
vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
|
|
53
8
|
|
|
54
9
|
// Mock dotenv
|
|
55
10
|
vi.mock('dotenv', () => mockDotenv());
|
|
@@ -270,14 +225,7 @@ describe('ghostServiceImproved - Tiers', () => {
|
|
|
270
225
|
|
|
271
226
|
const result = await getTier('tier-1');
|
|
272
227
|
|
|
273
|
-
expect(api.tiers.read).toHaveBeenCalledWith(
|
|
274
|
-
expect.objectContaining({
|
|
275
|
-
id: 'tier-1',
|
|
276
|
-
}),
|
|
277
|
-
expect.objectContaining({
|
|
278
|
-
id: 'tier-1',
|
|
279
|
-
})
|
|
280
|
-
);
|
|
228
|
+
expect(api.tiers.read).toHaveBeenCalledWith({}, { id: 'tier-1' });
|
|
281
229
|
expect(result).toEqual(mockTier);
|
|
282
230
|
});
|
|
283
231
|
|
|
@@ -327,14 +275,16 @@ describe('ghostServiceImproved - Tiers', () => {
|
|
|
327
275
|
|
|
328
276
|
const result = await updateTier('tier-1', updateData);
|
|
329
277
|
|
|
330
|
-
expect(api.tiers.read).toHaveBeenCalledWith(
|
|
331
|
-
expect.objectContaining({ id: 'tier-1' }),
|
|
332
|
-
expect.objectContaining({ id: 'tier-1' })
|
|
333
|
-
);
|
|
278
|
+
expect(api.tiers.read).toHaveBeenCalledWith({}, { id: 'tier-1' });
|
|
334
279
|
// Should send ONLY updateData + updated_at, NOT the full existing tier
|
|
335
280
|
expect(api.tiers.edit).toHaveBeenCalledWith(
|
|
336
|
-
{
|
|
337
|
-
|
|
281
|
+
{
|
|
282
|
+
id: 'tier-1',
|
|
283
|
+
name: 'Premium Plus',
|
|
284
|
+
monthly_price: 1299,
|
|
285
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
286
|
+
},
|
|
287
|
+
{}
|
|
338
288
|
);
|
|
339
289
|
// Verify read-only fields are NOT sent
|
|
340
290
|
const editCallData = api.tiers.edit.mock.calls[0][0];
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
validateMemberLookup,
|
|
7
7
|
validateSearchQuery,
|
|
8
8
|
validateSearchOptions,
|
|
9
|
-
sanitizeNqlValue,
|
|
10
9
|
} from '../memberService.js';
|
|
11
10
|
|
|
12
11
|
describe('memberService - Validation', () => {
|
|
@@ -447,31 +446,4 @@ describe('memberService - Validation', () => {
|
|
|
447
446
|
);
|
|
448
447
|
});
|
|
449
448
|
});
|
|
450
|
-
|
|
451
|
-
describe('sanitizeNqlValue', () => {
|
|
452
|
-
it('should escape backslashes', () => {
|
|
453
|
-
expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
it('should escape single quotes', () => {
|
|
457
|
-
expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it('should escape double quotes', () => {
|
|
461
|
-
expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it('should handle multiple special characters', () => {
|
|
465
|
-
expect(sanitizeNqlValue('test\'value"with\\chars')).toBe('test\\\'value\\"with\\\\chars');
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it('should not modify strings without special characters', () => {
|
|
469
|
-
expect(sanitizeNqlValue('normalvalue')).toBe('normalvalue');
|
|
470
|
-
expect(sanitizeNqlValue('test@example.com')).toBe('test@example.com');
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
it('should handle empty string', () => {
|
|
474
|
-
expect(sanitizeNqlValue('')).toBe('');
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
449
|
});
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
validateTierData,
|
|
4
4
|
validateTierUpdateData,
|
|
5
5
|
validateTierQueryOptions,
|
|
6
|
-
sanitizeNqlValue,
|
|
7
6
|
} from '../tierService.js';
|
|
8
7
|
import { ValidationError } from '../../errors/index.js';
|
|
9
8
|
|
|
@@ -342,31 +341,4 @@ describe('tierService - Validation', () => {
|
|
|
342
341
|
).not.toThrow();
|
|
343
342
|
});
|
|
344
343
|
});
|
|
345
|
-
|
|
346
|
-
describe('sanitizeNqlValue', () => {
|
|
347
|
-
it('should return value if undefined or null', () => {
|
|
348
|
-
expect(sanitizeNqlValue(null)).toBe(null);
|
|
349
|
-
expect(sanitizeNqlValue(undefined)).toBe(undefined);
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('should escape backslashes', () => {
|
|
353
|
-
expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it('should escape single quotes', () => {
|
|
357
|
-
expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it('should escape double quotes', () => {
|
|
361
|
-
expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it('should escape multiple special characters', () => {
|
|
365
|
-
expect(sanitizeNqlValue('test\\value"with\'quotes')).toBe('test\\\\value\\"with\\\'quotes');
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
it('should handle strings without special characters', () => {
|
|
369
|
-
expect(sanitizeNqlValue('simple-value')).toBe('simple-value');
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
344
|
});
|