@jgardner04/ghost-mcp-server 1.1.8 → 1.1.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
3
3
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
4
4
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
@@ -15,9 +15,218 @@ vi.mock('../../utils/logger.js', () => ({
15
15
  }));
16
16
 
17
17
  // Import after setting up mocks and environment
18
- import { createPost, createTag, getTags } from '../ghostService.js';
18
+ import {
19
+ createPost,
20
+ createTag,
21
+ getTags,
22
+ getSiteInfo,
23
+ uploadImage,
24
+ handleApiRequest,
25
+ api,
26
+ } from '../ghostService.js';
19
27
 
20
28
  describe('ghostService', () => {
29
+ beforeEach(() => {
30
+ // Reset all mocks before each test
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ describe('handleApiRequest', () => {
35
+ describe('success paths', () => {
36
+ it('should handle add action for posts successfully', async () => {
37
+ const expectedPost = { id: '1', title: 'Test Post', status: 'draft' };
38
+ api.posts.add.mockResolvedValue(expectedPost);
39
+
40
+ const result = await handleApiRequest('posts', 'add', { title: 'Test Post' });
41
+
42
+ expect(result).toEqual(expectedPost);
43
+ expect(api.posts.add).toHaveBeenCalledWith({ title: 'Test Post' });
44
+ });
45
+
46
+ it('should handle add action for tags successfully', async () => {
47
+ const expectedTag = { id: '1', name: 'Test Tag', slug: 'test-tag' };
48
+ api.tags.add.mockResolvedValue(expectedTag);
49
+
50
+ const result = await handleApiRequest('tags', 'add', { name: 'Test Tag' });
51
+
52
+ expect(result).toEqual(expectedTag);
53
+ expect(api.tags.add).toHaveBeenCalledWith({ name: 'Test Tag' });
54
+ });
55
+
56
+ it('should handle upload action for images successfully', async () => {
57
+ const expectedImage = { url: 'https://example.com/image.jpg' };
58
+ api.images.upload.mockResolvedValue(expectedImage);
59
+
60
+ const result = await handleApiRequest('images', 'upload', { file: '/path/to/image.jpg' });
61
+
62
+ expect(result).toEqual(expectedImage);
63
+ expect(api.images.upload).toHaveBeenCalledWith({ file: '/path/to/image.jpg' });
64
+ });
65
+
66
+ it('should handle browse action with options successfully', async () => {
67
+ const expectedTags = [
68
+ { id: '1', name: 'Tag1' },
69
+ { id: '2', name: 'Tag2' },
70
+ ];
71
+ api.tags.browse.mockResolvedValue(expectedTags);
72
+
73
+ const result = await handleApiRequest('tags', 'browse', {}, { limit: 'all' });
74
+
75
+ expect(result).toEqual(expectedTags);
76
+ expect(api.tags.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
77
+ });
78
+
79
+ it('should handle add action with options successfully', async () => {
80
+ const postData = { title: 'Test Post', html: '<p>Content</p>' };
81
+ const options = { source: 'html' };
82
+ const expectedPost = { id: '1', ...postData };
83
+ api.posts.add.mockResolvedValue(expectedPost);
84
+
85
+ const result = await handleApiRequest('posts', 'add', postData, options);
86
+
87
+ expect(result).toEqual(expectedPost);
88
+ expect(api.posts.add).toHaveBeenCalledWith(postData, options);
89
+ });
90
+ });
91
+
92
+ describe('error handling', () => {
93
+ it('should throw error for invalid resource', async () => {
94
+ await expect(handleApiRequest('invalid', 'add', {})).rejects.toThrow(
95
+ 'Invalid Ghost API resource or action: invalid.add'
96
+ );
97
+ });
98
+
99
+ it('should throw error for invalid action', async () => {
100
+ await expect(handleApiRequest('posts', 'invalid', {})).rejects.toThrow(
101
+ 'Invalid Ghost API resource or action: posts.invalid'
102
+ );
103
+ });
104
+
105
+ it('should handle 404 error and throw', async () => {
106
+ const error404 = new Error('Not found');
107
+ error404.response = { status: 404 };
108
+ api.posts.read.mockRejectedValue(error404);
109
+
110
+ await expect(handleApiRequest('posts', 'read', { id: 'nonexistent' })).rejects.toThrow(
111
+ 'Not found'
112
+ );
113
+ });
114
+ });
115
+
116
+ describe('retry logic', () => {
117
+ it('should retry on 429 rate limit error with delay', async () => {
118
+ const rateLimitError = new Error('Rate limit exceeded');
119
+ rateLimitError.response = { status: 429 };
120
+ const successResult = { id: '1', name: 'Success' };
121
+
122
+ api.tags.add.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(successResult);
123
+
124
+ const startTime = Date.now();
125
+ const result = await handleApiRequest('tags', 'add', { name: 'Test' });
126
+ const elapsedTime = Date.now() - startTime;
127
+
128
+ expect(result).toEqual(successResult);
129
+ expect(api.tags.add).toHaveBeenCalledTimes(2);
130
+ // Should have delayed at least 5000ms for rate limit
131
+ expect(elapsedTime).toBeGreaterThanOrEqual(4900); // Allow small margin
132
+ }, 10000); // 10 second timeout
133
+
134
+ it('should retry on 500 server error with increasing delay', async () => {
135
+ const serverError = new Error('Internal server error');
136
+ serverError.response = { status: 500 };
137
+ const successResult = { id: '1', title: 'Success' };
138
+
139
+ api.posts.add.mockRejectedValueOnce(serverError).mockResolvedValueOnce(successResult);
140
+
141
+ const result = await handleApiRequest('posts', 'add', { title: 'Test' });
142
+
143
+ expect(result).toEqual(successResult);
144
+ expect(api.posts.add).toHaveBeenCalledTimes(2);
145
+ });
146
+
147
+ it('should retry on ECONNREFUSED network error', async () => {
148
+ const networkError = new Error('Connection refused');
149
+ networkError.code = 'ECONNREFUSED';
150
+ const successResult = { id: '1', title: 'Success' };
151
+
152
+ api.posts.add.mockRejectedValueOnce(networkError).mockResolvedValueOnce(successResult);
153
+
154
+ const result = await handleApiRequest('posts', 'add', { title: 'Test' });
155
+
156
+ expect(result).toEqual(successResult);
157
+ expect(api.posts.add).toHaveBeenCalledTimes(2);
158
+ });
159
+
160
+ it('should retry on ETIMEDOUT network error', async () => {
161
+ const timeoutError = new Error('Connection timeout');
162
+ timeoutError.code = 'ETIMEDOUT';
163
+ const successResult = { id: '1', title: 'Success' };
164
+
165
+ api.posts.add.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(successResult);
166
+
167
+ const result = await handleApiRequest('posts', 'add', { title: 'Test' });
168
+
169
+ expect(result).toEqual(successResult);
170
+ expect(api.posts.add).toHaveBeenCalledTimes(2);
171
+ });
172
+
173
+ it('should throw error after exhausting retries', async () => {
174
+ const serverError = new Error('Server error');
175
+ serverError.response = { status: 500 };
176
+
177
+ api.posts.add.mockRejectedValue(serverError);
178
+
179
+ // Should retry 3 times (4 attempts total)
180
+ await expect(handleApiRequest('posts', 'add', { title: 'Test' })).rejects.toThrow(
181
+ 'Server error'
182
+ );
183
+ expect(api.posts.add).toHaveBeenCalledTimes(4); // Initial + 3 retries
184
+ }, 10000); // 10 second timeout
185
+
186
+ it('should not retry on non-retryable errors', async () => {
187
+ const badRequestError = new Error('Bad request');
188
+ badRequestError.response = { status: 400 };
189
+
190
+ api.posts.add.mockRejectedValue(badRequestError);
191
+
192
+ await expect(handleApiRequest('posts', 'add', { title: 'Test' })).rejects.toThrow(
193
+ 'Bad request'
194
+ );
195
+ expect(api.posts.add).toHaveBeenCalledTimes(1); // No retries
196
+ });
197
+ });
198
+ });
199
+
200
+ describe('getSiteInfo', () => {
201
+ it('should successfully retrieve site information', async () => {
202
+ const expectedSite = { title: 'Test Site', url: 'https://example.com' };
203
+ api.site.read.mockResolvedValue(expectedSite);
204
+
205
+ const result = await getSiteInfo();
206
+
207
+ expect(result).toEqual(expectedSite);
208
+ expect(api.site.read).toHaveBeenCalled();
209
+ });
210
+ });
211
+
212
+ describe('uploadImage', () => {
213
+ it('should throw error when image path is missing', async () => {
214
+ await expect(uploadImage()).rejects.toThrow('Image path is required for upload');
215
+ await expect(uploadImage('')).rejects.toThrow('Image path is required for upload');
216
+ });
217
+
218
+ it('should successfully upload image with valid path', async () => {
219
+ const imagePath = '/path/to/image.jpg';
220
+ const expectedResult = { url: 'https://example.com/uploaded-image.jpg' };
221
+ api.images.upload.mockResolvedValue(expectedResult);
222
+
223
+ const result = await uploadImage(imagePath);
224
+
225
+ expect(result).toEqual(expectedResult);
226
+ expect(api.images.upload).toHaveBeenCalledWith({ file: imagePath });
227
+ });
228
+ });
229
+
21
230
  describe('createPost', () => {
22
231
  it('should throw error when title is missing', async () => {
23
232
  await expect(createPost({})).rejects.toThrow('Post title is required');
@@ -25,15 +234,27 @@ describe('ghostService', () => {
25
234
 
26
235
  it('should set default status to draft when not provided', async () => {
27
236
  const postData = { title: 'Test Post', html: '<p>Content</p>' };
237
+ const expectedPost = { id: '1', title: 'Test Post', html: '<p>Content</p>', status: 'draft' };
238
+ api.posts.add.mockResolvedValue(expectedPost);
28
239
 
29
- // The function should call the API with default status
30
- try {
31
- await createPost(postData);
32
- } catch (_error) {
33
- // Expected to fail since we're using a mock, but we can verify the behavior
34
- }
240
+ const result = await createPost(postData);
241
+
242
+ expect(result).toEqual(expectedPost);
243
+ expect(api.posts.add).toHaveBeenCalledWith(
244
+ { status: 'draft', title: 'Test Post', html: '<p>Content</p>' },
245
+ { source: 'html' }
246
+ );
247
+ });
35
248
 
36
- expect(postData.title).toBe('Test Post');
249
+ it('should successfully create post with valid data', async () => {
250
+ const postData = { title: 'Test Post', html: '<p>Content</p>', status: 'published' };
251
+ const expectedPost = { id: '1', ...postData };
252
+ api.posts.add.mockResolvedValue(expectedPost);
253
+
254
+ const result = await createPost(postData);
255
+
256
+ expect(result).toEqual(expectedPost);
257
+ expect(api.posts.add).toHaveBeenCalled();
37
258
  });
38
259
  });
39
260
 
@@ -42,16 +263,15 @@ describe('ghostService', () => {
42
263
  await expect(createTag({})).rejects.toThrow('Tag name is required');
43
264
  });
44
265
 
45
- it('should accept valid tag data', async () => {
266
+ it('should successfully create tag with valid data', async () => {
46
267
  const tagData = { name: 'Test Tag', slug: 'test-tag' };
268
+ const expectedTag = { id: '1', ...tagData };
269
+ api.tags.add.mockResolvedValue(expectedTag);
47
270
 
48
- try {
49
- await createTag(tagData);
50
- } catch (_error) {
51
- // Expected to fail with mock, but validates input handling
52
- }
271
+ const result = await createTag(tagData);
53
272
 
54
- expect(tagData.name).toBe('Test Tag');
273
+ expect(result).toEqual(expectedTag);
274
+ expect(api.tags.add).toHaveBeenCalledWith(tagData);
55
275
  });
56
276
  });
57
277
 
@@ -64,26 +284,36 @@ describe('ghostService', () => {
64
284
 
65
285
  it('should accept valid tag names', async () => {
66
286
  const validNames = ['Test Tag', 'test-tag', 'test_tag', 'Tag123'];
287
+ const expectedTags = [{ id: '1', name: 'Tag' }];
288
+ api.tags.browse.mockResolvedValue(expectedTags);
67
289
 
68
290
  for (const name of validNames) {
69
- try {
70
- await getTags(name);
71
- } catch (_error) {
72
- // Expected to fail with mock, but should not throw validation error
73
- expect(_error.message).not.toContain('invalid characters');
74
- }
291
+ const result = await getTags(name);
292
+ expect(result).toEqual(expectedTags);
75
293
  }
76
294
  });
77
295
 
78
- it('should handle tag names without filter when name is not provided', async () => {
79
- try {
80
- await getTags();
81
- } catch (_error) {
82
- // Expected to fail with mock
83
- }
296
+ it('should handle tags without filter when name is not provided', async () => {
297
+ const expectedTags = [
298
+ { id: '1', name: 'Tag1' },
299
+ { id: '2', name: 'Tag2' },
300
+ ];
301
+ api.tags.browse.mockResolvedValue(expectedTags);
302
+
303
+ const result = await getTags();
304
+
305
+ expect(result).toEqual(expectedTags);
306
+ expect(api.tags.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
307
+ });
308
+
309
+ it('should properly escape tag names in filter', async () => {
310
+ const expectedTags = [{ id: '1', name: "Tag's Name" }];
311
+ api.tags.browse.mockResolvedValue(expectedTags);
312
+
313
+ // This should work because we properly escape single quotes
314
+ const result = await getTags('Valid Tag');
84
315
 
85
- // Should not throw validation error
86
- expect(true).toBe(true);
316
+ expect(result).toEqual(expectedTags);
87
317
  });
88
318
  });
89
319
  });
@@ -0,0 +1,504 @@
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 ghostService functions - must use factory pattern to avoid hoisting issues
14
+ vi.mock('../ghostService.js', () => ({
15
+ createPost: vi.fn(),
16
+ getTags: vi.fn(),
17
+ createTag: vi.fn(),
18
+ }));
19
+
20
+ // Import after mocks are set up
21
+ import { createPostService } from '../postService.js';
22
+ import { createPost, getTags, createTag } from '../ghostService.js';
23
+
24
+ describe('postService', () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ describe('createPostService - validation', () => {
30
+ it('should accept valid input and create a post', async () => {
31
+ const validInput = {
32
+ title: 'Test Post',
33
+ html: '<p>Test content</p>',
34
+ };
35
+ const expectedPost = { id: '1', title: 'Test Post', status: 'draft' };
36
+ createPost.mockResolvedValue(expectedPost);
37
+
38
+ const result = await createPostService(validInput);
39
+
40
+ expect(result).toEqual(expectedPost);
41
+ expect(createPost).toHaveBeenCalledWith(
42
+ expect.objectContaining({
43
+ title: 'Test Post',
44
+ html: '<p>Test content</p>',
45
+ status: 'draft',
46
+ })
47
+ );
48
+ });
49
+
50
+ it('should reject input with missing title', async () => {
51
+ const invalidInput = {
52
+ html: '<p>Test content</p>',
53
+ };
54
+
55
+ await expect(createPostService(invalidInput)).rejects.toThrow(
56
+ 'Invalid post input: "title" is required'
57
+ );
58
+ expect(createPost).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('should reject input with missing html', async () => {
62
+ const invalidInput = {
63
+ title: 'Test Post',
64
+ };
65
+
66
+ await expect(createPostService(invalidInput)).rejects.toThrow(
67
+ 'Invalid post input: "html" is required'
68
+ );
69
+ expect(createPost).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('should reject input with invalid status', async () => {
73
+ const invalidInput = {
74
+ title: 'Test Post',
75
+ html: '<p>Content</p>',
76
+ status: 'invalid-status',
77
+ };
78
+
79
+ await expect(createPostService(invalidInput)).rejects.toThrow(
80
+ 'Invalid post input: "status" must be one of [draft, published, scheduled]'
81
+ );
82
+ expect(createPost).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it('should accept valid status values', async () => {
86
+ const statuses = ['draft', 'published', 'scheduled'];
87
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
88
+
89
+ for (const status of statuses) {
90
+ const input = {
91
+ title: 'Test Post',
92
+ html: '<p>Content</p>',
93
+ status,
94
+ };
95
+
96
+ await createPostService(input);
97
+
98
+ expect(createPost).toHaveBeenCalledWith(expect.objectContaining({ status }));
99
+ vi.clearAllMocks();
100
+ }
101
+ });
102
+
103
+ it('should validate tags array with maximum length', async () => {
104
+ const invalidInput = {
105
+ title: 'Test Post',
106
+ html: '<p>Content</p>',
107
+ tags: Array(11).fill('tag'), // 11 tags exceeds max of 10
108
+ };
109
+
110
+ await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
111
+ expect(createPost).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('should validate tag string max length', async () => {
115
+ const invalidInput = {
116
+ title: 'Test Post',
117
+ html: '<p>Content</p>',
118
+ tags: ['a'.repeat(51)], // 51 chars exceeds max of 50
119
+ };
120
+
121
+ await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
122
+ expect(createPost).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('should validate feature_image is a valid URI', async () => {
126
+ const invalidInput = {
127
+ title: 'Test Post',
128
+ html: '<p>Content</p>',
129
+ feature_image: 'not-a-valid-url',
130
+ };
131
+
132
+ await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
133
+ expect(createPost).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('should accept valid feature_image URI', async () => {
137
+ const validInput = {
138
+ title: 'Test Post',
139
+ html: '<p>Content</p>',
140
+ feature_image: 'https://example.com/image.jpg',
141
+ };
142
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
143
+
144
+ await createPostService(validInput);
145
+
146
+ expect(createPost).toHaveBeenCalledWith(
147
+ expect.objectContaining({
148
+ feature_image: 'https://example.com/image.jpg',
149
+ })
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('createPostService - tag resolution', () => {
155
+ it('should find and reuse existing tag', async () => {
156
+ const input = {
157
+ title: 'Test Post',
158
+ html: '<p>Content</p>',
159
+ tags: ['existing-tag'],
160
+ };
161
+ const existingTag = { id: '1', name: 'existing-tag', slug: 'existing-tag' };
162
+ getTags.mockResolvedValue([existingTag]);
163
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
164
+
165
+ await createPostService(input);
166
+
167
+ expect(getTags).toHaveBeenCalledWith('existing-tag');
168
+ expect(createTag).not.toHaveBeenCalled();
169
+ expect(createPost).toHaveBeenCalledWith(
170
+ expect.objectContaining({
171
+ tags: [{ name: 'existing-tag' }],
172
+ })
173
+ );
174
+ });
175
+
176
+ it('should create new tag when not found', async () => {
177
+ const input = {
178
+ title: 'Test Post',
179
+ html: '<p>Content</p>',
180
+ tags: ['new-tag'],
181
+ };
182
+ getTags.mockResolvedValue([]); // Tag not found
183
+ const newTag = { id: '2', name: 'new-tag', slug: 'new-tag' };
184
+ createTag.mockResolvedValue(newTag);
185
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
186
+
187
+ await createPostService(input);
188
+
189
+ expect(getTags).toHaveBeenCalledWith('new-tag');
190
+ expect(createTag).toHaveBeenCalledWith({ name: 'new-tag' });
191
+ expect(createPost).toHaveBeenCalledWith(
192
+ expect.objectContaining({
193
+ tags: [{ name: 'new-tag' }],
194
+ })
195
+ );
196
+ });
197
+
198
+ it('should handle errors during tag lookup gracefully', async () => {
199
+ const input = {
200
+ title: 'Test Post',
201
+ html: '<p>Content</p>',
202
+ tags: ['error-tag', 'good-tag'],
203
+ };
204
+ // First tag causes error, second tag exists
205
+ getTags
206
+ .mockRejectedValueOnce(new Error('Tag lookup failed'))
207
+ .mockResolvedValueOnce([{ id: '1', name: 'good-tag' }]);
208
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
209
+
210
+ await createPostService(input);
211
+
212
+ // Should skip error-tag and only include good-tag
213
+ expect(createPost).toHaveBeenCalledWith(
214
+ expect.objectContaining({
215
+ tags: [{ name: 'good-tag' }],
216
+ })
217
+ );
218
+ });
219
+
220
+ it('should reject tags array with non-string values', async () => {
221
+ const input = {
222
+ title: 'Test Post',
223
+ html: '<p>Content</p>',
224
+ tags: [null, 'valid-tag'],
225
+ };
226
+
227
+ await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
228
+ expect(createPost).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it('should reject tags array with empty strings', async () => {
232
+ const input = {
233
+ title: 'Test Post',
234
+ html: '<p>Content</p>',
235
+ tags: ['', 'valid-tag'],
236
+ };
237
+
238
+ await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
239
+ expect(createPost).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it('should trim whitespace from tag names', async () => {
243
+ const input = {
244
+ title: 'Test Post',
245
+ html: '<p>Content</p>',
246
+ tags: [' trimmed-tag '],
247
+ };
248
+ getTags.mockResolvedValue([{ id: '1', name: 'trimmed-tag' }]);
249
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
250
+
251
+ await createPostService(input);
252
+
253
+ expect(getTags).toHaveBeenCalledWith('trimmed-tag');
254
+ expect(createPost).toHaveBeenCalledWith(
255
+ expect.objectContaining({
256
+ tags: [{ name: 'trimmed-tag' }],
257
+ })
258
+ );
259
+ });
260
+
261
+ it('should handle mixed existing and new tags', async () => {
262
+ const input = {
263
+ title: 'Test Post',
264
+ html: '<p>Content</p>',
265
+ tags: ['existing-tag', 'new-tag'],
266
+ };
267
+ getTags
268
+ .mockResolvedValueOnce([{ id: '1', name: 'existing-tag' }]) // First tag exists
269
+ .mockResolvedValueOnce([]); // Second tag doesn't exist
270
+ createTag.mockResolvedValue({ id: '2', name: 'new-tag' });
271
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
272
+
273
+ await createPostService(input);
274
+
275
+ expect(getTags).toHaveBeenCalledTimes(2);
276
+ expect(createTag).toHaveBeenCalledTimes(1);
277
+ expect(createPost).toHaveBeenCalledWith(
278
+ expect.objectContaining({
279
+ tags: [{ name: 'existing-tag' }, { name: 'new-tag' }],
280
+ })
281
+ );
282
+ });
283
+ });
284
+
285
+ describe('createPostService - metadata defaults', () => {
286
+ it('should default meta_title to title when not provided', async () => {
287
+ const input = {
288
+ title: 'Test Post Title',
289
+ html: '<p>Content</p>',
290
+ };
291
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
292
+
293
+ await createPostService(input);
294
+
295
+ expect(createPost).toHaveBeenCalledWith(
296
+ expect.objectContaining({
297
+ meta_title: 'Test Post Title',
298
+ })
299
+ );
300
+ });
301
+
302
+ it('should use provided meta_title instead of defaulting to title', async () => {
303
+ const input = {
304
+ title: 'Test Post Title',
305
+ html: '<p>Content</p>',
306
+ meta_title: 'Custom Meta Title',
307
+ };
308
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
309
+
310
+ await createPostService(input);
311
+
312
+ expect(createPost).toHaveBeenCalledWith(
313
+ expect.objectContaining({
314
+ meta_title: 'Custom Meta Title',
315
+ })
316
+ );
317
+ });
318
+
319
+ it('should default meta_description to custom_excerpt when provided', async () => {
320
+ const input = {
321
+ title: 'Test Post',
322
+ html: '<p>Long HTML content that would be stripped</p>',
323
+ custom_excerpt: 'This is the custom excerpt',
324
+ };
325
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
326
+
327
+ await createPostService(input);
328
+
329
+ expect(createPost).toHaveBeenCalledWith(
330
+ expect.objectContaining({
331
+ meta_description: 'This is the custom excerpt',
332
+ })
333
+ );
334
+ });
335
+
336
+ it('should generate meta_description from HTML when no excerpt provided', async () => {
337
+ const input = {
338
+ title: 'Test Post',
339
+ html: '<p>This is HTML content with tags stripped</p>',
340
+ };
341
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
342
+
343
+ await createPostService(input);
344
+
345
+ expect(createPost).toHaveBeenCalledWith(
346
+ expect.objectContaining({
347
+ meta_description: 'This is HTML content with tags stripped',
348
+ })
349
+ );
350
+ });
351
+
352
+ it('should use provided meta_description over custom_excerpt and HTML', async () => {
353
+ const input = {
354
+ title: 'Test Post',
355
+ html: '<p>HTML content</p>',
356
+ custom_excerpt: 'Custom excerpt',
357
+ meta_description: 'Explicit meta description',
358
+ };
359
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
360
+
361
+ await createPostService(input);
362
+
363
+ expect(createPost).toHaveBeenCalledWith(
364
+ expect.objectContaining({
365
+ meta_description: 'Explicit meta description',
366
+ })
367
+ );
368
+ });
369
+
370
+ it('should truncate meta_description to 500 characters with ellipsis when generated from long HTML', async () => {
371
+ const longHtml = '<p>' + 'a'.repeat(600) + '</p>';
372
+ const input = {
373
+ title: 'Test Post',
374
+ html: longHtml,
375
+ };
376
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
377
+
378
+ await createPostService(input);
379
+
380
+ const calledDescription = createPost.mock.calls[0][0].meta_description;
381
+ expect(calledDescription).toHaveLength(500);
382
+ expect(calledDescription.endsWith('...')).toBe(true);
383
+ expect(calledDescription).toBe('a'.repeat(497) + '...');
384
+ });
385
+
386
+ it('should reject empty HTML content', async () => {
387
+ const input = {
388
+ title: 'Test Post',
389
+ html: '',
390
+ };
391
+
392
+ await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
393
+ expect(createPost).not.toHaveBeenCalled();
394
+ });
395
+
396
+ it('should strip HTML tags and truncate when generating meta_description', async () => {
397
+ const longHtml = '<p>' + 'word '.repeat(200) + '</p>';
398
+ const input = {
399
+ title: 'Test Post',
400
+ html: longHtml,
401
+ };
402
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
403
+
404
+ await createPostService(input);
405
+
406
+ const calledDescription = createPost.mock.calls[0][0].meta_description;
407
+ expect(calledDescription).not.toContain('<p>');
408
+ expect(calledDescription).not.toContain('</p>');
409
+ expect(calledDescription.length).toBeLessThanOrEqual(500);
410
+ });
411
+ });
412
+
413
+ describe('createPostService - complete post creation', () => {
414
+ it('should create post with all optional fields', async () => {
415
+ const input = {
416
+ title: 'Complete Post',
417
+ html: '<p>Full content</p>',
418
+ custom_excerpt: 'Excerpt',
419
+ status: 'published',
420
+ published_at: '2025-12-10T12:00:00.000Z',
421
+ tags: ['tag1', 'tag2'],
422
+ feature_image: 'https://example.com/image.jpg',
423
+ feature_image_alt: 'Image alt text',
424
+ feature_image_caption: 'Image caption',
425
+ meta_title: 'Custom Meta Title',
426
+ meta_description: 'Custom meta description',
427
+ };
428
+ getTags.mockResolvedValue([]);
429
+ createTag
430
+ .mockResolvedValueOnce({ id: '1', name: 'tag1' })
431
+ .mockResolvedValueOnce({ id: '2', name: 'tag2' });
432
+ createPost.mockResolvedValue({ id: '1', title: 'Complete Post' });
433
+
434
+ await createPostService(input);
435
+
436
+ expect(createPost).toHaveBeenCalledWith({
437
+ title: 'Complete Post',
438
+ html: '<p>Full content</p>',
439
+ custom_excerpt: 'Excerpt',
440
+ status: 'published',
441
+ published_at: '2025-12-10T12:00:00.000Z',
442
+ tags: [{ name: 'tag1' }, { name: 'tag2' }],
443
+ feature_image: 'https://example.com/image.jpg',
444
+ feature_image_alt: 'Image alt text',
445
+ feature_image_caption: 'Image caption',
446
+ meta_title: 'Custom Meta Title',
447
+ meta_description: 'Custom meta description',
448
+ });
449
+ });
450
+
451
+ it('should default status to draft when not provided', async () => {
452
+ const input = {
453
+ title: 'Test Post',
454
+ html: '<p>Content</p>',
455
+ };
456
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
457
+
458
+ await createPostService(input);
459
+
460
+ expect(createPost).toHaveBeenCalledWith(
461
+ expect.objectContaining({
462
+ status: 'draft',
463
+ })
464
+ );
465
+ });
466
+
467
+ it('should handle post creation with no tags', async () => {
468
+ const input = {
469
+ title: 'Test Post',
470
+ html: '<p>Content</p>',
471
+ };
472
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
473
+
474
+ await createPostService(input);
475
+
476
+ expect(createPost).toHaveBeenCalledWith(
477
+ expect.objectContaining({
478
+ tags: [],
479
+ })
480
+ );
481
+ expect(getTags).not.toHaveBeenCalled();
482
+ expect(createTag).not.toHaveBeenCalled();
483
+ });
484
+
485
+ it('should handle post creation with empty tags array', async () => {
486
+ const input = {
487
+ title: 'Test Post',
488
+ html: '<p>Content</p>',
489
+ tags: [],
490
+ };
491
+ createPost.mockResolvedValue({ id: '1', title: 'Test' });
492
+
493
+ await createPostService(input);
494
+
495
+ expect(createPost).toHaveBeenCalledWith(
496
+ expect.objectContaining({
497
+ tags: [],
498
+ })
499
+ );
500
+ expect(getTags).not.toHaveBeenCalled();
501
+ expect(createTag).not.toHaveBeenCalled();
502
+ });
503
+ });
504
+ });