@jgardner04/ghost-mcp-server 1.13.3 → 1.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,25 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
2
3
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
3
5
 
4
- // Mock dotenv before other imports
6
+ // Mock the Ghost Admin API using shared mock factory
7
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
8
+
9
+ // Mock dotenv
5
10
  vi.mock('dotenv', () => mockDotenv());
6
11
 
7
- // Create a mock Ghost Admin API
8
- vi.mock('@tryghost/admin-api', () => {
9
- const mockNewslettersApi = {
10
- browse: vi.fn(),
11
- read: vi.fn(),
12
- add: vi.fn(),
13
- edit: vi.fn(),
14
- delete: vi.fn(),
15
- };
16
-
17
- return {
18
- default: class {
19
- constructor() {
20
- return {
21
- newsletters: mockNewslettersApi,
22
- };
23
- }
24
- },
25
- mockNewslettersApi,
26
- };
27
- });
12
+ // Mock logger
13
+ vi.mock('../../utils/logger.js', () => ({
14
+ createContextLogger: createMockContextLogger(),
15
+ }));
16
+
17
+ // Mock fs for validateImagePath
18
+ vi.mock('fs/promises', () => ({
19
+ default: {
20
+ access: vi.fn(),
21
+ },
22
+ }));
28
23
 
29
24
  // Import after mocks are set up
30
25
  import {
@@ -33,12 +28,10 @@ import {
33
28
  createNewsletter,
34
29
  updateNewsletter,
35
30
  deleteNewsletter,
31
+ api,
36
32
  } from '../ghostServiceImproved.js';
37
33
  import { ValidationError, NotFoundError } from '../../errors/index.js';
38
34
 
39
- // Get the mock API
40
- const { mockNewslettersApi } = await vi.importMock('@tryghost/admin-api');
41
-
42
35
  describe('ghostServiceImproved - Newsletter Operations', () => {
43
36
  beforeEach(() => {
44
37
  vi.clearAllMocks();
@@ -50,37 +43,37 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
50
43
  { id: '1', name: 'Newsletter 1', slug: 'newsletter-1' },
51
44
  { id: '2', name: 'Newsletter 2', slug: 'newsletter-2' },
52
45
  ];
53
- mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
46
+ api.newsletters.browse.mockResolvedValue(mockNewsletters);
54
47
 
55
48
  const result = await getNewsletters();
56
49
 
57
50
  expect(result).toEqual(mockNewsletters);
58
- expect(mockNewslettersApi.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
51
+ expect(api.newsletters.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
59
52
  });
60
53
 
61
54
  it('should support custom limit', async () => {
62
55
  const mockNewsletters = [{ id: '1', name: 'Newsletter 1' }];
63
- mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
56
+ api.newsletters.browse.mockResolvedValue(mockNewsletters);
64
57
 
65
58
  await getNewsletters({ limit: 5 });
66
59
 
67
- expect(mockNewslettersApi.browse).toHaveBeenCalledWith({ limit: 5 }, {});
60
+ expect(api.newsletters.browse).toHaveBeenCalledWith({ limit: 5 }, {});
68
61
  });
69
62
 
70
63
  it('should support filter option', async () => {
71
64
  const mockNewsletters = [{ id: '1', name: 'Active Newsletter' }];
72
- mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
65
+ api.newsletters.browse.mockResolvedValue(mockNewsletters);
73
66
 
74
67
  await getNewsletters({ filter: 'status:active' });
75
68
 
76
- expect(mockNewslettersApi.browse).toHaveBeenCalledWith(
69
+ expect(api.newsletters.browse).toHaveBeenCalledWith(
77
70
  { limit: 'all', filter: 'status:active' },
78
71
  {}
79
72
  );
80
73
  });
81
74
 
82
75
  it('should handle empty results', async () => {
83
- mockNewslettersApi.browse.mockResolvedValue([]);
76
+ api.newsletters.browse.mockResolvedValue([]);
84
77
 
85
78
  const result = await getNewsletters();
86
79
 
@@ -88,7 +81,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
88
81
  });
89
82
 
90
83
  it('should propagate API errors', async () => {
91
- mockNewslettersApi.browse.mockRejectedValue(new Error('API Error'));
84
+ api.newsletters.browse.mockRejectedValue(new Error('API Error'));
92
85
 
93
86
  await expect(getNewsletters()).rejects.toThrow();
94
87
  });
@@ -97,24 +90,24 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
97
90
  describe('getNewsletter', () => {
98
91
  it('should retrieve a newsletter by ID', async () => {
99
92
  const mockNewsletter = { id: 'newsletter-123', name: 'My Newsletter' };
100
- mockNewslettersApi.read.mockResolvedValue(mockNewsletter);
93
+ api.newsletters.read.mockResolvedValue(mockNewsletter);
101
94
 
102
95
  const result = await getNewsletter('newsletter-123');
103
96
 
104
97
  expect(result).toEqual(mockNewsletter);
105
- expect(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
98
+ expect(api.newsletters.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
106
99
  });
107
100
 
108
101
  it('should throw ValidationError if ID is missing', async () => {
109
102
  await expect(getNewsletter()).rejects.toThrow(ValidationError);
110
103
  await expect(getNewsletter()).rejects.toThrow('Newsletter ID is required');
111
- expect(mockNewslettersApi.read).not.toHaveBeenCalled();
104
+ expect(api.newsletters.read).not.toHaveBeenCalled();
112
105
  });
113
106
 
114
107
  it('should throw NotFoundError when newsletter does not exist', async () => {
115
108
  const ghostError = new Error('Not found');
116
109
  ghostError.response = { status: 404 };
117
- mockNewslettersApi.read.mockRejectedValue(ghostError);
110
+ api.newsletters.read.mockRejectedValue(ghostError);
118
111
 
119
112
  await expect(getNewsletter('nonexistent')).rejects.toThrow(NotFoundError);
120
113
  });
@@ -127,12 +120,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
127
120
  description: 'Our weekly updates',
128
121
  };
129
122
  const createdNewsletter = { id: '1', ...newsletterData };
130
- mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
123
+ api.newsletters.add.mockResolvedValue(createdNewsletter);
131
124
 
132
125
  const result = await createNewsletter(newsletterData);
133
126
 
134
127
  expect(result).toEqual(createdNewsletter);
135
- expect(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
128
+ expect(api.newsletters.add).toHaveBeenCalledWith(newsletterData, {});
136
129
  });
137
130
 
138
131
  it('should create newsletter with sender email', async () => {
@@ -143,12 +136,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
143
136
  sender_reply_to: 'newsletter',
144
137
  };
145
138
  const createdNewsletter = { id: '1', ...newsletterData };
146
- mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
139
+ api.newsletters.add.mockResolvedValue(createdNewsletter);
147
140
 
148
141
  const result = await createNewsletter(newsletterData);
149
142
 
150
143
  expect(result).toEqual(createdNewsletter);
151
- expect(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
144
+ expect(api.newsletters.add).toHaveBeenCalledWith(newsletterData, {});
152
145
  });
153
146
 
154
147
  it('should create newsletter with display options', async () => {
@@ -159,12 +152,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
159
152
  show_header_title: false,
160
153
  };
161
154
  const createdNewsletter = { id: '1', ...newsletterData };
162
- mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
155
+ api.newsletters.add.mockResolvedValue(createdNewsletter);
163
156
 
164
157
  const result = await createNewsletter(newsletterData);
165
158
 
166
159
  expect(result).toEqual(createdNewsletter);
167
- expect(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
160
+ expect(api.newsletters.add).toHaveBeenCalledWith(newsletterData, {});
168
161
  });
169
162
 
170
163
  it('should throw ValidationError if name is missing', async () => {
@@ -172,7 +165,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
172
165
 
173
166
  await expect(createNewsletter(invalidData)).rejects.toThrow(ValidationError);
174
167
  await expect(createNewsletter(invalidData)).rejects.toThrow('Newsletter validation failed');
175
- expect(mockNewslettersApi.add).not.toHaveBeenCalled();
168
+ expect(api.newsletters.add).not.toHaveBeenCalled();
176
169
  });
177
170
 
178
171
  it('should throw ValidationError if name is empty', async () => {
@@ -180,14 +173,14 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
180
173
 
181
174
  await expect(createNewsletter(invalidData)).rejects.toThrow(ValidationError);
182
175
  await expect(createNewsletter(invalidData)).rejects.toThrow('Newsletter validation failed');
183
- expect(mockNewslettersApi.add).not.toHaveBeenCalled();
176
+ expect(api.newsletters.add).not.toHaveBeenCalled();
184
177
  });
185
178
 
186
179
  it('should handle Ghost API validation errors', async () => {
187
180
  const newsletterData = { name: 'Newsletter' };
188
181
  const ghostError = new Error('Validation failed');
189
182
  ghostError.response = { status: 422 };
190
- mockNewslettersApi.add.mockRejectedValue(ghostError);
183
+ api.newsletters.add.mockRejectedValue(ghostError);
191
184
 
192
185
  await expect(createNewsletter(newsletterData)).rejects.toThrow();
193
186
  });
@@ -205,20 +198,20 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
205
198
  const updateData = { name: 'New Name' };
206
199
  const updatedNewsletter = { ...existingNewsletter, ...updateData };
207
200
 
208
- mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
209
- mockNewslettersApi.edit.mockResolvedValue(updatedNewsletter);
201
+ api.newsletters.read.mockResolvedValue(existingNewsletter);
202
+ api.newsletters.edit.mockResolvedValue(updatedNewsletter);
210
203
 
211
204
  const result = await updateNewsletter('newsletter-123', updateData);
212
205
 
213
206
  expect(result).toEqual(updatedNewsletter);
214
- expect(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
207
+ expect(api.newsletters.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
215
208
  // Should send ONLY updateData + updated_at, NOT the full existing newsletter
216
- expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
209
+ expect(api.newsletters.edit).toHaveBeenCalledWith(
217
210
  { id: 'newsletter-123', name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
218
211
  {}
219
212
  );
220
213
  // Verify read-only fields are NOT sent
221
- const editCallData = mockNewslettersApi.edit.mock.calls[0][0];
214
+ const editCallData = api.newsletters.edit.mock.calls[0][0];
222
215
  expect(editCallData).not.toHaveProperty('uuid');
223
216
  expect(editCallData).not.toHaveProperty('slug');
224
217
  });
@@ -235,13 +228,13 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
235
228
  subscribe_on_signup: false,
236
229
  };
237
230
 
238
- mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
239
- mockNewslettersApi.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
231
+ api.newsletters.read.mockResolvedValue(existingNewsletter);
232
+ api.newsletters.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
240
233
 
241
234
  await updateNewsletter('newsletter-123', updateData);
242
235
 
243
236
  // Should send ONLY updateData + updated_at
244
- expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
237
+ expect(api.newsletters.edit).toHaveBeenCalledWith(
245
238
  { id: 'newsletter-123', ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
246
239
  {}
247
240
  );
@@ -249,14 +242,14 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
249
242
 
250
243
  it('should throw ValidationError if ID is missing', async () => {
251
244
  await expect(updateNewsletter()).rejects.toThrow(ValidationError);
252
- await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required for update');
253
- expect(mockNewslettersApi.read).not.toHaveBeenCalled();
245
+ await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required');
246
+ expect(api.newsletters.read).not.toHaveBeenCalled();
254
247
  });
255
248
 
256
249
  it('should throw NotFoundError if newsletter does not exist', async () => {
257
250
  const ghostError = new Error('Not found');
258
251
  ghostError.response = { status: 404 };
259
- mockNewslettersApi.read.mockRejectedValue(ghostError);
252
+ api.newsletters.read.mockRejectedValue(ghostError);
260
253
 
261
254
  await expect(updateNewsletter('nonexistent', { name: 'New Name' })).rejects.toThrow(
262
255
  NotFoundError
@@ -271,13 +264,13 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
271
264
  };
272
265
  const updateData = { description: 'Updated description' };
273
266
 
274
- mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
275
- mockNewslettersApi.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
267
+ api.newsletters.read.mockResolvedValue(existingNewsletter);
268
+ api.newsletters.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
276
269
 
277
270
  await updateNewsletter('newsletter-123', updateData);
278
271
 
279
272
  // Should send ONLY updateData + updated_at
280
- expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
273
+ expect(api.newsletters.edit).toHaveBeenCalledWith(
281
274
  {
282
275
  id: 'newsletter-123',
283
276
  description: 'Updated description',
@@ -290,24 +283,24 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
290
283
 
291
284
  describe('deleteNewsletter', () => {
292
285
  it('should delete a newsletter successfully', async () => {
293
- mockNewslettersApi.delete.mockResolvedValue({ id: 'newsletter-123' });
286
+ api.newsletters.delete.mockResolvedValue({ id: 'newsletter-123' });
294
287
 
295
288
  const result = await deleteNewsletter('newsletter-123');
296
289
 
297
290
  expect(result).toEqual({ id: 'newsletter-123' });
298
- expect(mockNewslettersApi.delete).toHaveBeenCalledWith('newsletter-123', {});
291
+ expect(api.newsletters.delete).toHaveBeenCalledWith('newsletter-123', {});
299
292
  });
300
293
 
301
294
  it('should throw ValidationError if ID is missing', async () => {
302
295
  await expect(deleteNewsletter()).rejects.toThrow(ValidationError);
303
- await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required for deletion');
304
- expect(mockNewslettersApi.delete).not.toHaveBeenCalled();
296
+ await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required');
297
+ expect(api.newsletters.delete).not.toHaveBeenCalled();
305
298
  });
306
299
 
307
300
  it('should throw NotFoundError if newsletter does not exist', async () => {
308
301
  const ghostError = new Error('Not found');
309
302
  ghostError.response = { status: 404 };
310
- mockNewslettersApi.delete.mockRejectedValue(ghostError);
303
+ api.newsletters.delete.mockRejectedValue(ghostError);
311
304
 
312
305
  await expect(deleteNewsletter('nonexistent')).rejects.toThrow(NotFoundError);
313
306
  });
@@ -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 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
- }));
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 Error('Validation failed');
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
- await expect(createPage({ title: 'Test', html: '<p>Content</p>' })).rejects.toThrow(
244
- 'Page creation failed due to validation errors'
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 () => {
@@ -303,13 +273,12 @@ describe('ghostServiceImproved - Pages', () => {
303
273
  });
304
274
 
305
275
  it('should handle page not found (404)', async () => {
306
- const error404 = new Error('Page not found');
307
- error404.response = { status: 404 };
276
+ const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
308
277
  api.pages.read.mockRejectedValue(error404);
309
278
 
310
- await expect(updatePage('nonexistent-id', { title: 'Updated' })).rejects.toThrow(
311
- 'Page not found'
312
- );
279
+ const rejection = updatePage('nonexistent-id', { title: 'Updated' });
280
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
281
+ await expect(rejection).rejects.toThrow('Page not found');
313
282
  });
314
283
 
315
284
  it('should preserve updated_at timestamp for conflict resolution', async () => {
@@ -341,12 +310,67 @@ describe('ghostServiceImproved - Pages', () => {
341
310
  'Page validation failed'
342
311
  );
343
312
  });
313
+
314
+ it('should reject past published_at when existing page is scheduled (no status in update)', async () => {
315
+ const pageId = 'page-123';
316
+ const existingPage = {
317
+ id: pageId,
318
+ title: 'Scheduled Page',
319
+ status: 'scheduled',
320
+ published_at: new Date(Date.now() + 86400000).toISOString(),
321
+ updated_at: '2024-01-01T00:00:00.000Z',
322
+ };
323
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
324
+
325
+ api.pages.read.mockResolvedValue(existingPage);
326
+
327
+ await expect(updatePage(pageId, { published_at: pastDate })).rejects.toThrow(
328
+ 'Page validation failed'
329
+ );
330
+ });
331
+
332
+ it('should allow future published_at when existing page is scheduled (no status in update)', async () => {
333
+ const pageId = 'page-123';
334
+ const futureDate = new Date(Date.now() + 172800000).toISOString();
335
+ const existingPage = {
336
+ id: pageId,
337
+ title: 'Scheduled Page',
338
+ status: 'scheduled',
339
+ published_at: new Date(Date.now() + 86400000).toISOString(),
340
+ updated_at: '2024-01-01T00:00:00.000Z',
341
+ };
342
+
343
+ api.pages.read.mockResolvedValue(existingPage);
344
+ api.pages.edit.mockResolvedValue({ ...existingPage, published_at: futureDate });
345
+
346
+ const result = await updatePage(pageId, { published_at: futureDate });
347
+
348
+ expect(result).toBeDefined();
349
+ });
350
+
351
+ it('should allow published_at change when existing page is not scheduled', async () => {
352
+ const pageId = 'page-123';
353
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
354
+ const existingPage = {
355
+ id: pageId,
356
+ title: 'Draft Page',
357
+ status: 'draft',
358
+ updated_at: '2024-01-01T00:00:00.000Z',
359
+ };
360
+
361
+ api.pages.read.mockResolvedValue(existingPage);
362
+ api.pages.edit.mockResolvedValue({ ...existingPage, published_at: pastDate });
363
+
364
+ const result = await updatePage(pageId, { published_at: pastDate });
365
+
366
+ expect(result).toBeDefined();
367
+ });
344
368
  });
345
369
 
346
370
  describe('deletePage', () => {
347
371
  it('should throw error when page ID is missing', async () => {
348
- await expect(deletePage(null)).rejects.toThrow('Page ID is required for deletion');
349
- await expect(deletePage('')).rejects.toThrow('Page ID is required for deletion');
372
+ await expect(deletePage(null)).rejects.toThrow('Page ID is required');
373
+ await expect(deletePage('')).rejects.toThrow('Page ID is required');
350
374
  });
351
375
 
352
376
  it('should delete page successfully', async () => {
@@ -361,11 +385,12 @@ describe('ghostServiceImproved - Pages', () => {
361
385
  });
362
386
 
363
387
  it('should handle page not found (404)', async () => {
364
- const error404 = new Error('Page not found');
365
- error404.response = { status: 404 };
388
+ const error404 = new GhostAPIError('pages.delete', 'Page not found', 404);
366
389
  api.pages.delete.mockRejectedValue(error404);
367
390
 
368
- await expect(deletePage('nonexistent-id')).rejects.toThrow('Page not found');
391
+ const rejection = deletePage('nonexistent-id');
392
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
393
+ await expect(rejection).rejects.toThrow('Page not found');
369
394
  });
370
395
  });
371
396
 
@@ -409,11 +434,12 @@ describe('ghostServiceImproved - Pages', () => {
409
434
  });
410
435
 
411
436
  it('should handle page not found (404)', async () => {
412
- const error404 = new Error('Page not found');
413
- error404.response = { status: 404 };
437
+ const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
414
438
  api.pages.read.mockRejectedValue(error404);
415
439
 
416
- await expect(getPage('nonexistent-id')).rejects.toThrow('Page not found');
440
+ const rejection = getPage('nonexistent-id');
441
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
442
+ await expect(rejection).rejects.toThrow('Page not found');
417
443
  });
418
444
  });
419
445
 
@@ -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 with posts 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
- 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(() => {
@@ -124,22 +87,17 @@ 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
- 'Post ID is required for update'
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 Error('Post not found');
137
- error404.response = { status: 404 };
95
+ const error404 = new GhostAPIError('posts.read', 'Post not found', 404);
138
96
  api.posts.read.mockRejectedValue(error404);
139
97
 
140
- await expect(updatePost('nonexistent-id', { title: 'Updated' })).rejects.toThrow(
141
- 'Post not found'
142
- );
98
+ const rejection = updatePost('nonexistent-id', { title: 'Updated' });
99
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
100
+ await expect(rejection).rejects.toThrow('Post not found');
143
101
  });
144
102
 
145
103
  it('should throw ValidationError when updating to scheduled without published_at', async () => {
@@ -177,6 +135,61 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
177
135
  {}
178
136
  );
179
137
  });
138
+
139
+ it('should reject past published_at when existing post is scheduled (no status in update)', async () => {
140
+ const postId = 'post-123';
141
+ const existingPost = {
142
+ id: postId,
143
+ title: 'Scheduled Post',
144
+ status: 'scheduled',
145
+ published_at: new Date(Date.now() + 86400000).toISOString(),
146
+ updated_at: '2024-01-01T00:00:00.000Z',
147
+ };
148
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
149
+
150
+ api.posts.read.mockResolvedValue(existingPost);
151
+
152
+ await expect(updatePost(postId, { published_at: pastDate })).rejects.toThrow(
153
+ 'Post validation failed'
154
+ );
155
+ });
156
+
157
+ it('should allow future published_at when existing post is scheduled (no status in update)', async () => {
158
+ const postId = 'post-123';
159
+ const futureDate = new Date(Date.now() + 172800000).toISOString();
160
+ const existingPost = {
161
+ id: postId,
162
+ title: 'Scheduled Post',
163
+ status: 'scheduled',
164
+ published_at: new Date(Date.now() + 86400000).toISOString(),
165
+ updated_at: '2024-01-01T00:00:00.000Z',
166
+ };
167
+
168
+ api.posts.read.mockResolvedValue(existingPost);
169
+ api.posts.edit.mockResolvedValue({ ...existingPost, published_at: futureDate });
170
+
171
+ const result = await updatePost(postId, { published_at: futureDate });
172
+
173
+ expect(result).toBeDefined();
174
+ });
175
+
176
+ it('should allow published_at change when existing post is not scheduled', async () => {
177
+ const postId = 'post-123';
178
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
179
+ const existingPost = {
180
+ id: postId,
181
+ title: 'Draft Post',
182
+ status: 'draft',
183
+ updated_at: '2024-01-01T00:00:00.000Z',
184
+ };
185
+
186
+ api.posts.read.mockResolvedValue(existingPost);
187
+ api.posts.edit.mockResolvedValue({ ...existingPost, published_at: pastDate });
188
+
189
+ const result = await updatePost(postId, { published_at: pastDate });
190
+
191
+ expect(result).toBeDefined();
192
+ });
180
193
  });
181
194
 
182
195
  describe('validators.validateScheduledStatus', () => {