@jgardner04/ghost-mcp-server 1.4.0 → 1.5.0

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,561 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+
5
+ // Mock the Ghost Admin API with pages 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
+ site: {
31
+ read: vi.fn(),
32
+ },
33
+ images: {
34
+ upload: vi.fn(),
35
+ },
36
+ };
37
+ }),
38
+ }));
39
+
40
+ // Mock dotenv
41
+ vi.mock('dotenv', () => mockDotenv());
42
+
43
+ // Mock logger
44
+ vi.mock('../../utils/logger.js', () => ({
45
+ createContextLogger: createMockContextLogger(),
46
+ }));
47
+
48
+ // Mock fs for validateImagePath (not needed for pages but part of validators)
49
+ vi.mock('fs/promises', () => ({
50
+ default: {
51
+ access: vi.fn(),
52
+ },
53
+ }));
54
+
55
+ // Import after setting up mocks
56
+ import {
57
+ createPage,
58
+ updatePage,
59
+ deletePage,
60
+ getPage,
61
+ getPages,
62
+ searchPages,
63
+ api,
64
+ validators,
65
+ } from '../ghostServiceImproved.js';
66
+
67
+ describe('ghostServiceImproved - Pages', () => {
68
+ beforeEach(() => {
69
+ // Reset all mocks before each test
70
+ vi.clearAllMocks();
71
+ });
72
+
73
+ describe('validators.validatePageData', () => {
74
+ it('should validate required title field', () => {
75
+ expect(() => validators.validatePageData({})).toThrow('Page validation failed');
76
+ expect(() => validators.validatePageData({ title: '' })).toThrow('Page validation failed');
77
+ expect(() => validators.validatePageData({ title: ' ' })).toThrow('Page validation failed');
78
+ });
79
+
80
+ it('should validate required content field (html or mobiledoc)', () => {
81
+ expect(() => validators.validatePageData({ title: 'Test Page' })).toThrow(
82
+ 'Page validation failed'
83
+ );
84
+ });
85
+
86
+ it('should accept valid html content', () => {
87
+ expect(() =>
88
+ validators.validatePageData({ title: 'Test Page', html: '<p>Content</p>' })
89
+ ).not.toThrow();
90
+ });
91
+
92
+ it('should accept valid mobiledoc content', () => {
93
+ expect(() =>
94
+ validators.validatePageData({ title: 'Test Page', mobiledoc: '{"version":"0.3.1"}' })
95
+ ).not.toThrow();
96
+ });
97
+
98
+ it('should validate status enum values', () => {
99
+ expect(() =>
100
+ validators.validatePageData({ title: 'Test', html: '<p>Content</p>', status: 'invalid' })
101
+ ).toThrow('Page validation failed');
102
+
103
+ // Valid status values should not throw
104
+ expect(() =>
105
+ validators.validatePageData({ title: 'Test', html: '<p>Content</p>', status: 'draft' })
106
+ ).not.toThrow();
107
+ expect(() =>
108
+ validators.validatePageData({ title: 'Test', html: '<p>Content</p>', status: 'published' })
109
+ ).not.toThrow();
110
+ // Note: scheduled without published_at will throw, tested separately
111
+ });
112
+
113
+ it('should require published_at when status is scheduled', () => {
114
+ expect(() =>
115
+ validators.validatePageData({ title: 'Test', html: '<p>Content</p>', status: 'scheduled' })
116
+ ).toThrow('Page validation failed');
117
+ });
118
+
119
+ it('should validate published_at date format', () => {
120
+ expect(() =>
121
+ validators.validatePageData({
122
+ title: 'Test',
123
+ html: '<p>Content</p>',
124
+ published_at: 'invalid-date',
125
+ })
126
+ ).toThrow('Page validation failed');
127
+ });
128
+
129
+ it('should require future date for scheduled pages', () => {
130
+ const pastDate = new Date(Date.now() - 86400000).toISOString(); // Yesterday
131
+ expect(() =>
132
+ validators.validatePageData({
133
+ title: 'Test',
134
+ html: '<p>Content</p>',
135
+ status: 'scheduled',
136
+ published_at: pastDate,
137
+ })
138
+ ).toThrow('Page validation failed');
139
+ });
140
+
141
+ it('should accept future date for scheduled pages', () => {
142
+ const futureDate = new Date(Date.now() + 86400000).toISOString(); // Tomorrow
143
+ expect(() =>
144
+ validators.validatePageData({
145
+ title: 'Test',
146
+ html: '<p>Content</p>',
147
+ status: 'scheduled',
148
+ published_at: futureDate,
149
+ })
150
+ ).not.toThrow();
151
+ });
152
+
153
+ it('should NOT validate tags field (pages do not support tags)', () => {
154
+ // This test ensures that tags are not part of page validation
155
+ // Pages should work with or without tags field (it will be ignored by Ghost API)
156
+ expect(() =>
157
+ validators.validatePageData({
158
+ title: 'Test',
159
+ html: '<p>Content</p>',
160
+ tags: ['tag1', 'tag2'],
161
+ })
162
+ ).not.toThrow();
163
+ });
164
+ });
165
+
166
+ describe('createPage', () => {
167
+ it('should throw validation error when title is missing', async () => {
168
+ await expect(createPage({ html: '<p>Content</p>' })).rejects.toThrow('Page validation');
169
+ });
170
+
171
+ it('should throw validation error when content is missing', async () => {
172
+ await expect(createPage({ title: 'Test Page' })).rejects.toThrow('Page validation');
173
+ });
174
+
175
+ it('should create page with minimal valid data', async () => {
176
+ const pageData = { title: 'Test Page', html: '<p>Content</p>' };
177
+ const expectedPage = { id: '1', ...pageData, status: 'draft' };
178
+ api.pages.add.mockResolvedValue(expectedPage);
179
+
180
+ const result = await createPage(pageData);
181
+
182
+ expect(result).toEqual(expectedPage);
183
+ expect(api.pages.add).toHaveBeenCalledWith(
184
+ { status: 'draft', ...pageData },
185
+ { source: 'html' }
186
+ );
187
+ });
188
+
189
+ it('should set default status to draft when not provided', async () => {
190
+ const pageData = { title: 'Test Page', html: '<p>Content</p>' };
191
+ api.pages.add.mockResolvedValue({ id: '1', ...pageData, status: 'draft' });
192
+
193
+ await createPage(pageData);
194
+
195
+ expect(api.pages.add).toHaveBeenCalledWith(
196
+ expect.objectContaining({ status: 'draft' }),
197
+ expect.any(Object)
198
+ );
199
+ });
200
+
201
+ it('should create page with all optional fields', async () => {
202
+ const pageData = {
203
+ title: 'Test Page',
204
+ html: '<p>Content</p>',
205
+ status: 'published',
206
+ custom_excerpt: 'Test excerpt',
207
+ feature_image: 'https://example.com/image.jpg',
208
+ feature_image_alt: 'Alt text',
209
+ feature_image_caption: 'Caption',
210
+ meta_title: 'Meta Title',
211
+ meta_description: 'Meta description',
212
+ };
213
+ const expectedPage = { id: '1', ...pageData };
214
+ api.pages.add.mockResolvedValue(expectedPage);
215
+
216
+ const result = await createPage(pageData);
217
+
218
+ expect(result).toEqual(expectedPage);
219
+ expect(api.pages.add).toHaveBeenCalledWith(pageData, { source: 'html' });
220
+ });
221
+
222
+ it('should sanitize HTML content', async () => {
223
+ const pageData = {
224
+ title: 'Test Page',
225
+ html: '<p>Safe content</p><script>alert("xss")</script>',
226
+ };
227
+ api.pages.add.mockResolvedValue({ id: '1', ...pageData });
228
+
229
+ await createPage(pageData);
230
+
231
+ // Verify that the HTML was sanitized (script tags removed)
232
+ const calledWith = api.pages.add.mock.calls[0][0];
233
+ expect(calledWith.html).not.toContain('<script>');
234
+ expect(calledWith.html).toContain('<p>Safe content</p>');
235
+ });
236
+
237
+ it('should handle Ghost API validation errors (422)', async () => {
238
+ const error422 = new Error('Validation failed');
239
+ error422.response = { status: 422 };
240
+ api.pages.add.mockRejectedValue(error422);
241
+
242
+ await expect(createPage({ title: 'Test', html: '<p>Content</p>' })).rejects.toThrow(
243
+ 'Page creation failed due to validation errors'
244
+ );
245
+ });
246
+
247
+ it('should NOT include tags in page creation (pages do not support tags)', async () => {
248
+ const pageData = {
249
+ title: 'Test Page',
250
+ html: '<p>Content</p>',
251
+ tags: ['tag1', 'tag2'], // This should be ignored
252
+ };
253
+ api.pages.add.mockResolvedValue({ id: '1', title: 'Test Page', html: '<p>Content</p>' });
254
+
255
+ await createPage(pageData);
256
+
257
+ // Verify that tags were passed through (Ghost API will ignore them for pages)
258
+ const calledWith = api.pages.add.mock.calls[0][0];
259
+ expect(calledWith).toMatchObject({ title: 'Test Page', html: expect.any(String) });
260
+ });
261
+ });
262
+
263
+ describe('updatePage', () => {
264
+ it('should throw error when page ID is missing', async () => {
265
+ await expect(updatePage(null, { title: 'Updated' })).rejects.toThrow('Page ID is required');
266
+ await expect(updatePage('', { title: 'Updated' })).rejects.toThrow('Page ID is required');
267
+ });
268
+
269
+ it('should update page with valid data', async () => {
270
+ const pageId = 'page-123';
271
+ const existingPage = {
272
+ id: pageId,
273
+ title: 'Original Title',
274
+ html: '<p>Original content</p>',
275
+ updated_at: '2024-01-01T00:00:00.000Z',
276
+ };
277
+ const updateData = { title: 'Updated Title' };
278
+ const expectedPage = { ...existingPage, ...updateData };
279
+
280
+ api.pages.read.mockResolvedValue(existingPage);
281
+ api.pages.edit.mockResolvedValue(expectedPage);
282
+
283
+ const result = await updatePage(pageId, updateData);
284
+
285
+ expect(result).toEqual(expectedPage);
286
+ // handleApiRequest calls read with (options, data), where options={} and data={id}
287
+ expect(api.pages.read).toHaveBeenCalledWith({}, { id: pageId });
288
+ expect(api.pages.edit).toHaveBeenCalledWith(
289
+ { ...existingPage, ...updateData },
290
+ { id: pageId }
291
+ );
292
+ });
293
+
294
+ it('should handle page not found (404)', async () => {
295
+ const error404 = new Error('Page not found');
296
+ error404.response = { status: 404 };
297
+ api.pages.read.mockRejectedValue(error404);
298
+
299
+ await expect(updatePage('nonexistent-id', { title: 'Updated' })).rejects.toThrow(
300
+ 'Page not found'
301
+ );
302
+ });
303
+
304
+ it('should preserve updated_at timestamp for conflict resolution', async () => {
305
+ const pageId = 'page-123';
306
+ const existingPage = {
307
+ id: pageId,
308
+ title: 'Original',
309
+ updated_at: '2024-01-01T00:00:00.000Z',
310
+ };
311
+ api.pages.read.mockResolvedValue(existingPage);
312
+ api.pages.edit.mockResolvedValue({ ...existingPage, title: 'Updated' });
313
+
314
+ await updatePage(pageId, { title: 'Updated' });
315
+
316
+ const editCall = api.pages.edit.mock.calls[0][0];
317
+ expect(editCall.updated_at).toBe('2024-01-01T00:00:00.000Z');
318
+ });
319
+
320
+ it('should sanitize HTML content in updates', async () => {
321
+ const pageId = 'page-123';
322
+ const existingPage = { id: pageId, title: 'Test', updated_at: '2024-01-01T00:00:00.000Z' };
323
+ api.pages.read.mockResolvedValue(existingPage);
324
+ api.pages.edit.mockResolvedValue({ ...existingPage });
325
+
326
+ await updatePage(pageId, { html: '<p>Safe</p><script>alert("xss")</script>' });
327
+
328
+ const editCall = api.pages.edit.mock.calls[0][0];
329
+ expect(editCall.html).not.toContain('<script>');
330
+ });
331
+ });
332
+
333
+ describe('deletePage', () => {
334
+ it('should throw error when page ID is missing', async () => {
335
+ await expect(deletePage(null)).rejects.toThrow('Page ID is required');
336
+ await expect(deletePage('')).rejects.toThrow('Page ID is required');
337
+ });
338
+
339
+ it('should delete page successfully', async () => {
340
+ const pageId = 'page-123';
341
+ api.pages.delete.mockResolvedValue({ id: pageId });
342
+
343
+ const result = await deletePage(pageId);
344
+
345
+ expect(result).toEqual({ id: pageId });
346
+ // handleApiRequest calls delete with (data.id || data, options)
347
+ expect(api.pages.delete).toHaveBeenCalledWith(pageId, {});
348
+ });
349
+
350
+ it('should handle page not found (404)', async () => {
351
+ const error404 = new Error('Page not found');
352
+ error404.response = { status: 404 };
353
+ api.pages.delete.mockRejectedValue(error404);
354
+
355
+ await expect(deletePage('nonexistent-id')).rejects.toThrow('Page not found');
356
+ });
357
+ });
358
+
359
+ describe('getPage', () => {
360
+ it('should throw error when page ID is missing', async () => {
361
+ await expect(getPage(null)).rejects.toThrow('Page ID is required');
362
+ await expect(getPage('')).rejects.toThrow('Page ID is required');
363
+ });
364
+
365
+ it('should get page by ID', async () => {
366
+ const pageId = 'page-123';
367
+ const expectedPage = { id: pageId, title: 'Test Page', html: '<p>Content</p>' };
368
+ api.pages.read.mockResolvedValue(expectedPage);
369
+
370
+ const result = await getPage(pageId);
371
+
372
+ expect(result).toEqual(expectedPage);
373
+ // handleApiRequest calls read with (options, data)
374
+ expect(api.pages.read).toHaveBeenCalledWith({}, { id: pageId });
375
+ });
376
+
377
+ it('should get page by slug', async () => {
378
+ const slug = 'test-page';
379
+ const expectedPage = { id: 'page-123', slug, title: 'Test Page' };
380
+ api.pages.read.mockResolvedValue(expectedPage);
381
+
382
+ const result = await getPage(`slug/${slug}`);
383
+
384
+ expect(result).toEqual(expectedPage);
385
+ expect(api.pages.read).toHaveBeenCalledWith({}, { id: `slug/${slug}` });
386
+ });
387
+
388
+ it('should pass options to API (include, etc.)', async () => {
389
+ const pageId = 'page-123';
390
+ const options = { include: 'authors' };
391
+ api.pages.read.mockResolvedValue({ id: pageId });
392
+
393
+ await getPage(pageId, options);
394
+
395
+ expect(api.pages.read).toHaveBeenCalledWith(options, { id: pageId });
396
+ });
397
+
398
+ it('should handle page not found (404)', async () => {
399
+ const error404 = new Error('Page not found');
400
+ error404.response = { status: 404 };
401
+ api.pages.read.mockRejectedValue(error404);
402
+
403
+ await expect(getPage('nonexistent-id')).rejects.toThrow('Page not found');
404
+ });
405
+ });
406
+
407
+ describe('getPages', () => {
408
+ it('should get all pages with default options', async () => {
409
+ const expectedPages = [
410
+ { id: '1', title: 'Page 1' },
411
+ { id: '2', title: 'Page 2' },
412
+ ];
413
+ api.pages.browse.mockResolvedValue(expectedPages);
414
+
415
+ const result = await getPages();
416
+
417
+ expect(result).toEqual(expectedPages);
418
+ // getPages applies defaults (limit: 15, include: 'authors')
419
+ expect(api.pages.browse).toHaveBeenCalledWith({ limit: 15, include: 'authors' }, {});
420
+ });
421
+
422
+ it('should pass pagination options', async () => {
423
+ const options = { limit: 10, page: 2 };
424
+ api.pages.browse.mockResolvedValue([]);
425
+
426
+ await getPages(options);
427
+
428
+ // getPages merges options with defaults
429
+ expect(api.pages.browse).toHaveBeenCalledWith(
430
+ expect.objectContaining({ limit: 10, page: 2, include: 'authors' }),
431
+ {}
432
+ );
433
+ });
434
+
435
+ it('should pass status filter', async () => {
436
+ const options = { status: 'published' };
437
+ api.pages.browse.mockResolvedValue([]);
438
+
439
+ await getPages(options);
440
+
441
+ expect(api.pages.browse).toHaveBeenCalledWith(
442
+ expect.objectContaining({ status: 'published', limit: 15, include: 'authors' }),
443
+ {}
444
+ );
445
+ });
446
+
447
+ it('should pass include options', async () => {
448
+ const options = { include: 'authors,tags' };
449
+ api.pages.browse.mockResolvedValue([]);
450
+
451
+ await getPages(options);
452
+
453
+ expect(api.pages.browse).toHaveBeenCalledWith(
454
+ expect.objectContaining({ include: 'authors,tags', limit: 15 }),
455
+ {}
456
+ );
457
+ });
458
+
459
+ it('should pass NQL filter', async () => {
460
+ const options = { filter: 'featured:true' };
461
+ api.pages.browse.mockResolvedValue([]);
462
+
463
+ await getPages(options);
464
+
465
+ expect(api.pages.browse).toHaveBeenCalledWith(
466
+ expect.objectContaining({ filter: 'featured:true', limit: 15, include: 'authors' }),
467
+ {}
468
+ );
469
+ });
470
+
471
+ it('should pass order/sort options', async () => {
472
+ const options = { order: 'published_at DESC' };
473
+ api.pages.browse.mockResolvedValue([]);
474
+
475
+ await getPages(options);
476
+
477
+ expect(api.pages.browse).toHaveBeenCalledWith(
478
+ expect.objectContaining({ order: 'published_at DESC', limit: 15, include: 'authors' }),
479
+ {}
480
+ );
481
+ });
482
+ });
483
+
484
+ describe('searchPages', () => {
485
+ it('should throw error when query is missing', async () => {
486
+ await expect(searchPages(null)).rejects.toThrow('Search query is required');
487
+ await expect(searchPages('')).rejects.toThrow('Search query is required');
488
+ });
489
+
490
+ it('should search pages with query', async () => {
491
+ const query = 'test search';
492
+ const expectedPages = [{ id: '1', title: 'Test Page' }];
493
+ api.pages.browse.mockResolvedValue(expectedPages);
494
+
495
+ const result = await searchPages(query);
496
+
497
+ expect(result).toEqual(expectedPages);
498
+ // Verify NQL filter was created with escaped query
499
+ const browseCall = api.pages.browse.mock.calls[0][0];
500
+ expect(browseCall.filter).toContain('title:~');
501
+ expect(browseCall.filter).toContain('test search');
502
+ });
503
+
504
+ it('should sanitize query to prevent NQL injection', async () => {
505
+ const maliciousQuery = "test'; DELETE FROM pages; --";
506
+ api.pages.browse.mockResolvedValue([]);
507
+
508
+ await searchPages(maliciousQuery);
509
+
510
+ const browseCall = api.pages.browse.mock.calls[0][0];
511
+ // Verify that backslashes and quotes are escaped
512
+ expect(browseCall.filter).toContain("\\'");
513
+ });
514
+
515
+ it('should escape backslashes in query', async () => {
516
+ const query = 'test\\path';
517
+ api.pages.browse.mockResolvedValue([]);
518
+
519
+ await searchPages(query);
520
+
521
+ const browseCall = api.pages.browse.mock.calls[0][0];
522
+ expect(browseCall.filter).toContain('\\\\');
523
+ });
524
+
525
+ it('should pass status filter option', async () => {
526
+ const query = 'test';
527
+ const options = { status: 'published' };
528
+ api.pages.browse.mockResolvedValue([]);
529
+
530
+ await searchPages(query, options);
531
+
532
+ const browseCall = api.pages.browse.mock.calls[0][0];
533
+ expect(browseCall.filter).toContain('status:published');
534
+ });
535
+
536
+ it('should pass limit option', async () => {
537
+ const query = 'test';
538
+ const options = { limit: 5 };
539
+ api.pages.browse.mockResolvedValue([]);
540
+
541
+ await searchPages(query, options);
542
+
543
+ const browseCall = api.pages.browse.mock.calls[0][0];
544
+ expect(browseCall.limit).toBe(5);
545
+ });
546
+
547
+ it('should combine query and status in NQL filter', async () => {
548
+ const query = 'about';
549
+ const options = { status: 'published' };
550
+ api.pages.browse.mockResolvedValue([]);
551
+
552
+ await searchPages(query, options);
553
+
554
+ const browseCall = api.pages.browse.mock.calls[0][0];
555
+ expect(browseCall.filter).toContain('title:~');
556
+ expect(browseCall.filter).toContain('about');
557
+ expect(browseCall.filter).toContain('status:published');
558
+ expect(browseCall.filter).toContain('+');
559
+ });
560
+ });
561
+ });