@jgardner04/ghost-mcp-server 1.3.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,400 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
3
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
4
+
5
+ // Mock dotenv
6
+ vi.mock('dotenv', () => mockDotenv());
7
+
8
+ // Mock logger
9
+ vi.mock('../../utils/logger.js', () => ({
10
+ createContextLogger: createMockContextLogger(),
11
+ }));
12
+
13
+ // Mock ghostServiceImproved functions
14
+ vi.mock('../ghostServiceImproved.js', () => ({
15
+ createPage: vi.fn(),
16
+ }));
17
+
18
+ // Import after mocks are set up
19
+ import { createPageService } from '../pageService.js';
20
+ import { createPage } from '../ghostServiceImproved.js';
21
+
22
+ describe('pageService', () => {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ describe('createPageService - validation', () => {
28
+ it('should accept valid input and create a page', async () => {
29
+ const validInput = {
30
+ title: 'Test Page',
31
+ html: '<p>Test content</p>',
32
+ };
33
+ const expectedPage = { id: '1', title: 'Test Page', status: 'draft' };
34
+ createPage.mockResolvedValue(expectedPage);
35
+
36
+ const result = await createPageService(validInput);
37
+
38
+ expect(result).toEqual(expectedPage);
39
+ expect(createPage).toHaveBeenCalledWith(
40
+ expect.objectContaining({
41
+ title: 'Test Page',
42
+ html: '<p>Test content</p>',
43
+ status: 'draft',
44
+ })
45
+ );
46
+ });
47
+
48
+ it('should reject input with missing title', async () => {
49
+ const invalidInput = {
50
+ html: '<p>Test content</p>',
51
+ };
52
+
53
+ await expect(createPageService(invalidInput)).rejects.toThrow(
54
+ 'Invalid page input: "title" is required'
55
+ );
56
+ expect(createPage).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('should reject input with missing html', async () => {
60
+ const invalidInput = {
61
+ title: 'Test Page',
62
+ };
63
+
64
+ await expect(createPageService(invalidInput)).rejects.toThrow(
65
+ 'Invalid page input: "html" is required'
66
+ );
67
+ expect(createPage).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('should reject input with invalid status', async () => {
71
+ const invalidInput = {
72
+ title: 'Test Page',
73
+ html: '<p>Content</p>',
74
+ status: 'invalid-status',
75
+ };
76
+
77
+ await expect(createPageService(invalidInput)).rejects.toThrow(
78
+ 'Invalid page input: "status" must be one of [draft, published, scheduled]'
79
+ );
80
+ expect(createPage).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it('should accept valid status values', async () => {
84
+ const statuses = ['draft', 'published', 'scheduled'];
85
+ createPage.mockResolvedValue({ id: '1', title: 'Test' });
86
+
87
+ for (const status of statuses) {
88
+ const input = {
89
+ title: 'Test Page',
90
+ html: '<p>Content</p>',
91
+ status,
92
+ };
93
+
94
+ await createPageService(input);
95
+
96
+ expect(createPage).toHaveBeenCalledWith(expect.objectContaining({ status }));
97
+ vi.clearAllMocks();
98
+ }
99
+ });
100
+
101
+ it('should reject tags field (pages do not support tags)', async () => {
102
+ const invalidInput = {
103
+ title: 'Test Page',
104
+ html: '<p>Content</p>',
105
+ tags: ['tag1', 'tag2'],
106
+ };
107
+
108
+ // Tags field should cause validation error since it's not in the schema
109
+ await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
110
+ expect(createPage).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('should validate feature_image is a valid URI', async () => {
114
+ const invalidInput = {
115
+ title: 'Test Page',
116
+ html: '<p>Content</p>',
117
+ feature_image: 'not-a-valid-url',
118
+ };
119
+
120
+ await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
121
+ expect(createPage).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('should accept valid feature_image URI', async () => {
125
+ const validInput = {
126
+ title: 'Test Page',
127
+ html: '<p>Content</p>',
128
+ feature_image: 'https://example.com/image.jpg',
129
+ };
130
+ createPage.mockResolvedValue({ id: '1', title: 'Test' });
131
+
132
+ await createPageService(validInput);
133
+
134
+ expect(createPage).toHaveBeenCalledWith(
135
+ expect.objectContaining({
136
+ feature_image: 'https://example.com/image.jpg',
137
+ })
138
+ );
139
+ });
140
+
141
+ it('should validate title max length', async () => {
142
+ const invalidInput = {
143
+ title: 'a'.repeat(256), // 256 chars exceeds max of 255
144
+ html: '<p>Content</p>',
145
+ };
146
+
147
+ await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
148
+ expect(createPage).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it('should validate custom_excerpt max length', async () => {
152
+ const invalidInput = {
153
+ title: 'Test Page',
154
+ html: '<p>Content</p>',
155
+ custom_excerpt: 'a'.repeat(501), // 501 chars exceeds max of 500
156
+ };
157
+
158
+ await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
159
+ expect(createPage).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('should validate meta_title max length', async () => {
163
+ const invalidInput = {
164
+ title: 'Test Page',
165
+ html: '<p>Content</p>',
166
+ meta_title: 'a'.repeat(71), // 71 chars exceeds max of 70
167
+ };
168
+
169
+ await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
170
+ expect(createPage).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it('should validate meta_description max length', async () => {
174
+ const invalidInput = {
175
+ title: 'Test Page',
176
+ html: '<p>Content</p>',
177
+ meta_description: 'a'.repeat(161), // 161 chars exceeds max of 160
178
+ };
179
+
180
+ await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
181
+ expect(createPage).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it('should validate published_at is ISO date format', async () => {
185
+ const invalidInput = {
186
+ title: 'Test Page',
187
+ html: '<p>Content</p>',
188
+ published_at: 'invalid-date',
189
+ };
190
+
191
+ await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
192
+ expect(createPage).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('should accept valid published_at ISO date', async () => {
196
+ const validInput = {
197
+ title: 'Test Page',
198
+ html: '<p>Content</p>',
199
+ published_at: '2024-12-31T12:00:00.000Z',
200
+ };
201
+ createPage.mockResolvedValue({ id: '1', title: 'Test' });
202
+
203
+ await createPageService(validInput);
204
+
205
+ expect(createPage).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ published_at: '2024-12-31T12:00:00.000Z',
208
+ })
209
+ );
210
+ });
211
+ });
212
+
213
+ describe('createPageService - metadata generation', () => {
214
+ beforeEach(() => {
215
+ createPage.mockResolvedValue({ id: '1', title: 'Test' });
216
+ });
217
+
218
+ it('should default meta_title to title when not provided', async () => {
219
+ const input = {
220
+ title: 'My Page Title',
221
+ html: '<p>Content</p>',
222
+ };
223
+
224
+ await createPageService(input);
225
+
226
+ expect(createPage).toHaveBeenCalledWith(
227
+ expect.objectContaining({
228
+ meta_title: 'My Page Title',
229
+ })
230
+ );
231
+ });
232
+
233
+ it('should use provided meta_title instead of defaulting', async () => {
234
+ const input = {
235
+ title: 'My Page Title',
236
+ html: '<p>Content</p>',
237
+ meta_title: 'Custom Meta Title',
238
+ };
239
+
240
+ await createPageService(input);
241
+
242
+ expect(createPage).toHaveBeenCalledWith(
243
+ expect.objectContaining({
244
+ meta_title: 'Custom Meta Title',
245
+ })
246
+ );
247
+ });
248
+
249
+ it('should default meta_description to custom_excerpt when provided', async () => {
250
+ const input = {
251
+ title: 'Test Page',
252
+ html: '<p>Content</p>',
253
+ custom_excerpt: 'This is my custom excerpt',
254
+ };
255
+
256
+ await createPageService(input);
257
+
258
+ expect(createPage).toHaveBeenCalledWith(
259
+ expect.objectContaining({
260
+ meta_description: 'This is my custom excerpt',
261
+ })
262
+ );
263
+ });
264
+
265
+ it('should generate meta_description from HTML when not provided', async () => {
266
+ const input = {
267
+ title: 'Test Page',
268
+ html: '<p>This is the page content that will be used for meta description.</p>',
269
+ };
270
+
271
+ await createPageService(input);
272
+
273
+ expect(createPage).toHaveBeenCalledWith(
274
+ expect.objectContaining({
275
+ meta_description: expect.stringContaining('This is the page content'),
276
+ })
277
+ );
278
+ });
279
+
280
+ it('should use provided meta_description over custom_excerpt', async () => {
281
+ const input = {
282
+ title: 'Test Page',
283
+ html: '<p>Content</p>',
284
+ custom_excerpt: 'This is the excerpt',
285
+ meta_description: 'This is the explicit meta description',
286
+ };
287
+
288
+ await createPageService(input);
289
+
290
+ expect(createPage).toHaveBeenCalledWith(
291
+ expect.objectContaining({
292
+ meta_description: 'This is the explicit meta description',
293
+ })
294
+ );
295
+ });
296
+
297
+ it('should strip HTML tags when generating meta_description', async () => {
298
+ const input = {
299
+ title: 'Test Page',
300
+ html: '<h1>Heading</h1><p><strong>Bold</strong> and <em>italic</em> text</p>',
301
+ };
302
+
303
+ await createPageService(input);
304
+
305
+ const calledWith = createPage.mock.calls[0][0];
306
+ expect(calledWith.meta_description).not.toContain('<');
307
+ expect(calledWith.meta_description).not.toContain('>');
308
+ expect(calledWith.meta_description).toContain('Heading');
309
+ expect(calledWith.meta_description).toContain('Bold');
310
+ expect(calledWith.meta_description).toContain('italic');
311
+ });
312
+
313
+ it('should truncate meta_description to 500 characters', async () => {
314
+ const longContent = 'a'.repeat(600);
315
+ const input = {
316
+ title: 'Test Page',
317
+ html: `<p>${longContent}</p>`,
318
+ };
319
+
320
+ await createPageService(input);
321
+
322
+ const calledWith = createPage.mock.calls[0][0];
323
+ expect(calledWith.meta_description.length).toBeLessThanOrEqual(500);
324
+ expect(calledWith.meta_description).toContain('...');
325
+ });
326
+
327
+ it('should handle empty HTML content gracefully', async () => {
328
+ const input = {
329
+ title: 'Test Page',
330
+ html: '',
331
+ };
332
+
333
+ // Empty html should fail validation
334
+ await expect(createPageService(input)).rejects.toThrow('Invalid page input:');
335
+ });
336
+ });
337
+
338
+ describe('createPageService - complete page creation', () => {
339
+ it('should create page with all optional fields', async () => {
340
+ const fullInput = {
341
+ title: 'Complete Page',
342
+ html: '<p>Full content</p>',
343
+ custom_excerpt: 'Page excerpt',
344
+ status: 'published',
345
+ published_at: '2024-12-31T12:00:00.000Z',
346
+ feature_image: 'https://example.com/image.jpg',
347
+ feature_image_alt: 'Alt text',
348
+ feature_image_caption: 'Image caption',
349
+ meta_title: 'SEO Title',
350
+ meta_description: 'SEO Description',
351
+ };
352
+ const expectedPage = { id: '1', ...fullInput };
353
+ createPage.mockResolvedValue(expectedPage);
354
+
355
+ const result = await createPageService(fullInput);
356
+
357
+ expect(result).toEqual(expectedPage);
358
+ expect(createPage).toHaveBeenCalledWith(
359
+ expect.objectContaining({
360
+ title: 'Complete Page',
361
+ html: '<p>Full content</p>',
362
+ custom_excerpt: 'Page excerpt',
363
+ status: 'published',
364
+ published_at: '2024-12-31T12:00:00.000Z',
365
+ feature_image: 'https://example.com/image.jpg',
366
+ feature_image_alt: 'Alt text',
367
+ feature_image_caption: 'Image caption',
368
+ meta_title: 'SEO Title',
369
+ meta_description: 'SEO Description',
370
+ })
371
+ );
372
+ });
373
+
374
+ it('should default status to draft when not provided', async () => {
375
+ const input = {
376
+ title: 'Test Page',
377
+ html: '<p>Content</p>',
378
+ };
379
+ createPage.mockResolvedValue({ id: '1', title: 'Test', status: 'draft' });
380
+
381
+ await createPageService(input);
382
+
383
+ expect(createPage).toHaveBeenCalledWith(
384
+ expect.objectContaining({
385
+ status: 'draft',
386
+ })
387
+ );
388
+ });
389
+
390
+ it('should propagate errors from ghostServiceImproved', async () => {
391
+ const input = {
392
+ title: 'Test Page',
393
+ html: '<p>Content</p>',
394
+ };
395
+ createPage.mockRejectedValue(new Error('Ghost API error'));
396
+
397
+ await expect(createPageService(input)).rejects.toThrow('Ghost API error');
398
+ });
399
+ });
400
+ });
@@ -185,6 +185,45 @@ const validators = {
185
185
  throw new NotFoundError('Image file', imagePath);
186
186
  }
187
187
  },
188
+
189
+ validatePageData(pageData) {
190
+ const errors = [];
191
+
192
+ if (!pageData.title || pageData.title.trim().length === 0) {
193
+ errors.push({ field: 'title', message: 'Title is required' });
194
+ }
195
+
196
+ if (!pageData.html && !pageData.mobiledoc) {
197
+ errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
198
+ }
199
+
200
+ if (pageData.status && !['draft', 'published', 'scheduled'].includes(pageData.status)) {
201
+ errors.push({
202
+ field: 'status',
203
+ message: 'Invalid status. Must be draft, published, or scheduled',
204
+ });
205
+ }
206
+
207
+ if (pageData.status === 'scheduled' && !pageData.published_at) {
208
+ errors.push({
209
+ field: 'published_at',
210
+ message: 'published_at is required when status is scheduled',
211
+ });
212
+ }
213
+
214
+ if (pageData.published_at) {
215
+ const publishDate = new Date(pageData.published_at);
216
+ if (isNaN(publishDate.getTime())) {
217
+ errors.push({ field: 'published_at', message: 'Invalid date format' });
218
+ } else if (pageData.status === 'scheduled' && publishDate <= new Date()) {
219
+ errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
220
+ }
221
+ }
222
+
223
+ if (errors.length > 0) {
224
+ throw new ValidationError('Page validation failed', errors);
225
+ }
226
+ },
188
227
  };
189
228
 
190
229
  /**
@@ -336,6 +375,249 @@ export async function getPosts(options = {}) {
336
375
  }
337
376
  }
338
377
 
378
+ export async function searchPosts(query, options = {}) {
379
+ // Validate query
380
+ if (!query || query.trim().length === 0) {
381
+ throw new ValidationError('Search query is required');
382
+ }
383
+
384
+ // Sanitize query - escape special NQL characters to prevent injection
385
+ const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
386
+
387
+ // Build filter with fuzzy title match using Ghost NQL
388
+ const filterParts = [`title:~'${sanitizedQuery}'`];
389
+
390
+ // Add status filter if provided and not 'all'
391
+ if (options.status && options.status !== 'all') {
392
+ filterParts.push(`status:${options.status}`);
393
+ }
394
+
395
+ const searchOptions = {
396
+ limit: options.limit || 15,
397
+ include: 'tags,authors',
398
+ filter: filterParts.join('+'),
399
+ };
400
+
401
+ try {
402
+ return await handleApiRequest('posts', 'browse', {}, searchOptions);
403
+ } catch (error) {
404
+ console.error('Failed to search posts:', error);
405
+ throw error;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Page CRUD Operations
411
+ * Pages are similar to posts but do NOT support tags
412
+ */
413
+
414
+ export async function createPage(pageData, options = { source: 'html' }) {
415
+ // Validate input
416
+ validators.validatePageData(pageData);
417
+
418
+ // Add defaults
419
+ const dataWithDefaults = {
420
+ status: 'draft',
421
+ ...pageData,
422
+ };
423
+
424
+ // Sanitize HTML content if provided (use same sanitization as posts)
425
+ if (dataWithDefaults.html) {
426
+ dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
427
+ allowedTags: [
428
+ 'h1',
429
+ 'h2',
430
+ 'h3',
431
+ 'h4',
432
+ 'h5',
433
+ 'h6',
434
+ 'blockquote',
435
+ 'p',
436
+ 'a',
437
+ 'ul',
438
+ 'ol',
439
+ 'nl',
440
+ 'li',
441
+ 'b',
442
+ 'i',
443
+ 'strong',
444
+ 'em',
445
+ 'strike',
446
+ 'code',
447
+ 'hr',
448
+ 'br',
449
+ 'div',
450
+ 'span',
451
+ 'img',
452
+ 'pre',
453
+ ],
454
+ allowedAttributes: {
455
+ a: ['href', 'title'],
456
+ img: ['src', 'alt', 'title', 'width', 'height'],
457
+ '*': ['class', 'id'],
458
+ },
459
+ allowedSchemes: ['http', 'https', 'mailto'],
460
+ allowedSchemesByTag: {
461
+ img: ['http', 'https', 'data'],
462
+ },
463
+ });
464
+ }
465
+
466
+ try {
467
+ return await handleApiRequest('pages', 'add', dataWithDefaults, options);
468
+ } catch (error) {
469
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
470
+ throw new ValidationError('Page creation failed due to validation errors', [
471
+ { field: 'page', message: error.originalError },
472
+ ]);
473
+ }
474
+ throw error;
475
+ }
476
+ }
477
+
478
+ export async function updatePage(pageId, updateData, options = {}) {
479
+ if (!pageId) {
480
+ throw new ValidationError('Page ID is required for update');
481
+ }
482
+
483
+ // Sanitize HTML if being updated
484
+ if (updateData.html) {
485
+ updateData.html = sanitizeHtml(updateData.html, {
486
+ allowedTags: [
487
+ 'h1',
488
+ 'h2',
489
+ 'h3',
490
+ 'h4',
491
+ 'h5',
492
+ 'h6',
493
+ 'blockquote',
494
+ 'p',
495
+ 'a',
496
+ 'ul',
497
+ 'ol',
498
+ 'nl',
499
+ 'li',
500
+ 'b',
501
+ 'i',
502
+ 'strong',
503
+ 'em',
504
+ 'strike',
505
+ 'code',
506
+ 'hr',
507
+ 'br',
508
+ 'div',
509
+ 'span',
510
+ 'img',
511
+ 'pre',
512
+ ],
513
+ allowedAttributes: {
514
+ a: ['href', 'title'],
515
+ img: ['src', 'alt', 'title', 'width', 'height'],
516
+ '*': ['class', 'id'],
517
+ },
518
+ allowedSchemes: ['http', 'https', 'mailto'],
519
+ allowedSchemesByTag: {
520
+ img: ['http', 'https', 'data'],
521
+ },
522
+ });
523
+ }
524
+
525
+ try {
526
+ // Get existing page to retrieve updated_at for conflict resolution
527
+ const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
528
+
529
+ // Merge existing data with updates, preserving updated_at
530
+ const mergedData = {
531
+ ...existingPage,
532
+ ...updateData,
533
+ updated_at: existingPage.updated_at,
534
+ };
535
+
536
+ return await handleApiRequest('pages', 'edit', mergedData, { id: pageId, ...options });
537
+ } catch (error) {
538
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
539
+ throw new NotFoundError('Page', pageId);
540
+ }
541
+ throw error;
542
+ }
543
+ }
544
+
545
+ export async function deletePage(pageId) {
546
+ if (!pageId) {
547
+ throw new ValidationError('Page ID is required for delete');
548
+ }
549
+
550
+ try {
551
+ return await handleApiRequest('pages', 'delete', { id: pageId });
552
+ } catch (error) {
553
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
554
+ throw new NotFoundError('Page', pageId);
555
+ }
556
+ throw error;
557
+ }
558
+ }
559
+
560
+ export async function getPage(pageId, options = {}) {
561
+ if (!pageId) {
562
+ throw new ValidationError('Page ID is required');
563
+ }
564
+
565
+ try {
566
+ return await handleApiRequest('pages', 'read', { id: pageId }, options);
567
+ } catch (error) {
568
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
569
+ throw new NotFoundError('Page', pageId);
570
+ }
571
+ throw error;
572
+ }
573
+ }
574
+
575
+ export async function getPages(options = {}) {
576
+ const defaultOptions = {
577
+ limit: 15,
578
+ include: 'authors',
579
+ ...options,
580
+ };
581
+
582
+ try {
583
+ return await handleApiRequest('pages', 'browse', {}, defaultOptions);
584
+ } catch (error) {
585
+ console.error('Failed to get pages:', error);
586
+ throw error;
587
+ }
588
+ }
589
+
590
+ export async function searchPages(query, options = {}) {
591
+ // Validate query
592
+ if (!query || query.trim().length === 0) {
593
+ throw new ValidationError('Search query is required');
594
+ }
595
+
596
+ // Sanitize query - escape special NQL characters to prevent injection
597
+ const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
598
+
599
+ // Build filter with fuzzy title match using Ghost NQL
600
+ const filterParts = [`title:~'${sanitizedQuery}'`];
601
+
602
+ // Add status filter if provided and not 'all'
603
+ if (options.status && options.status !== 'all') {
604
+ filterParts.push(`status:${options.status}`);
605
+ }
606
+
607
+ const searchOptions = {
608
+ limit: options.limit || 15,
609
+ include: 'authors',
610
+ filter: filterParts.join('+'),
611
+ };
612
+
613
+ try {
614
+ return await handleApiRequest('pages', 'browse', {}, searchOptions);
615
+ } catch (error) {
616
+ console.error('Failed to search pages:', error);
617
+ throw error;
618
+ }
619
+ }
620
+
339
621
  export async function uploadImage(imagePath) {
340
622
  // Validate input
341
623
  await validators.validateImagePath(imagePath);
@@ -495,6 +777,13 @@ export default {
495
777
  deletePost,
496
778
  getPost,
497
779
  getPosts,
780
+ searchPosts,
781
+ createPage,
782
+ updatePage,
783
+ deletePage,
784
+ getPage,
785
+ getPages,
786
+ searchPages,
498
787
  uploadImage,
499
788
  createTag,
500
789
  getTags,