@jgardner04/ghost-mcp-server 1.13.3 → 1.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.13.3",
3
+ "version": "1.13.4",
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",
@@ -5,7 +5,11 @@ import { vi } from 'vitest';
5
5
  *
6
6
  * @param {Object} options - Configuration options for the mock
7
7
  * @param {Object} options.posts - Mock implementations for posts methods
8
+ * @param {Object} options.pages - Mock implementations for pages methods
8
9
  * @param {Object} options.tags - Mock implementations for tags methods
10
+ * @param {Object} options.members - Mock implementations for members methods
11
+ * @param {Object} options.newsletters - Mock implementations for newsletters methods
12
+ * @param {Object} options.tiers - Mock implementations for tiers methods
9
13
  * @param {Object} options.site - Mock implementations for site methods
10
14
  * @param {Object} options.images - Mock implementations for images methods
11
15
  * @returns {Object} Mock Ghost Admin API instance
@@ -32,6 +36,14 @@ export function createMockGhostApi(options = {}) {
32
36
  delete: vi.fn(),
33
37
  ...options.posts,
34
38
  },
39
+ pages: {
40
+ add: vi.fn(),
41
+ browse: vi.fn(),
42
+ read: vi.fn(),
43
+ edit: vi.fn(),
44
+ delete: vi.fn(),
45
+ ...options.pages,
46
+ },
35
47
  tags: {
36
48
  add: vi.fn(),
37
49
  browse: vi.fn(),
@@ -40,6 +52,30 @@ export function createMockGhostApi(options = {}) {
40
52
  delete: vi.fn(),
41
53
  ...options.tags,
42
54
  },
55
+ members: {
56
+ add: vi.fn(),
57
+ browse: vi.fn(),
58
+ read: vi.fn(),
59
+ edit: vi.fn(),
60
+ delete: vi.fn(),
61
+ ...options.members,
62
+ },
63
+ newsletters: {
64
+ add: vi.fn(),
65
+ browse: vi.fn(),
66
+ read: vi.fn(),
67
+ edit: vi.fn(),
68
+ delete: vi.fn(),
69
+ ...options.newsletters,
70
+ },
71
+ tiers: {
72
+ add: vi.fn(),
73
+ browse: vi.fn(),
74
+ read: vi.fn(),
75
+ edit: vi.fn(),
76
+ delete: vi.fn(),
77
+ ...options.tiers,
78
+ },
43
79
  site: {
44
80
  read: vi.fn(),
45
81
  ...options.site,
@@ -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 members 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());
@@ -69,6 +31,7 @@ import {
69
31
  searchMembers,
70
32
  api,
71
33
  } from '../ghostServiceImproved.js';
34
+ import { GhostAPIError, NotFoundError } from '../../errors/index.js';
72
35
 
73
36
  describe('ghostServiceImproved - Members', () => {
74
37
  beforeEach(() => {
@@ -214,21 +177,20 @@ describe('ghostServiceImproved - Members', () => {
214
177
  });
215
178
 
216
179
  it('should throw validation error for missing member ID', async () => {
217
- await expect(updateMember(null, { name: 'Test' })).rejects.toThrow(
218
- 'Member ID is required for update'
219
- );
180
+ await expect(updateMember(null, { name: 'Test' })).rejects.toThrow('Member ID is required');
220
181
  });
221
182
 
222
183
  // NOTE: Input validation tests (invalid email in update) have been moved to
223
184
  // MCP layer tests. The service layer now relies on Zod schema validation.
224
185
 
225
186
  it('should throw not found error if member does not exist', async () => {
226
- api.members.read.mockRejectedValue({
227
- response: { status: 404 },
228
- message: 'Member not found',
229
- });
187
+ const error404 = new GhostAPIError('members.read', 'Member not found', 404);
188
+ error404.response = { status: 404 };
189
+ api.members.read.mockRejectedValue(error404);
230
190
 
231
- await expect(updateMember('non-existent', { name: 'Test' })).rejects.toThrow();
191
+ const rejection = updateMember('non-existent', { name: 'Test' });
192
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
193
+ await expect(rejection).rejects.toThrow('Member not found');
232
194
  });
233
195
  });
234
196
 
@@ -245,16 +207,17 @@ describe('ghostServiceImproved - Members', () => {
245
207
  });
246
208
 
247
209
  it('should throw validation error for missing member ID', async () => {
248
- await expect(deleteMember(null)).rejects.toThrow('Member ID is required for deletion');
210
+ await expect(deleteMember(null)).rejects.toThrow('Member ID is required');
249
211
  });
250
212
 
251
213
  it('should throw not found error if member does not exist', async () => {
252
- api.members.delete.mockRejectedValue({
253
- response: { status: 404 },
254
- message: 'Member not found',
255
- });
214
+ const error404 = new GhostAPIError('members.delete', 'Member not found', 404);
215
+ error404.response = { status: 404 };
216
+ api.members.delete.mockRejectedValue(error404);
256
217
 
257
- await expect(deleteMember('non-existent')).rejects.toThrow();
218
+ const rejection = deleteMember('non-existent');
219
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
220
+ await expect(rejection).rejects.toThrow('Member not found');
258
221
  });
259
222
  });
260
223
 
@@ -396,12 +359,13 @@ describe('ghostServiceImproved - Members', () => {
396
359
  // moved to MCP layer tests. The service layer now relies on Zod schema validation.
397
360
 
398
361
  it('should throw not found error when member not found by ID', async () => {
399
- api.members.read.mockRejectedValue({
400
- response: { status: 404 },
401
- message: 'Member not found',
402
- });
362
+ const error404 = new GhostAPIError('members.read', 'Member not found', 404);
363
+ error404.response = { status: 404 };
364
+ api.members.read.mockRejectedValue(error404);
403
365
 
404
- await expect(getMember({ id: 'non-existent' })).rejects.toThrow();
366
+ const rejection = getMember({ id: 'non-existent' });
367
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
368
+ await expect(rejection).rejects.toThrow('Member not found');
405
369
  });
406
370
 
407
371
  it('should throw not found error when member not found by email', async () => {
@@ -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
  );
@@ -250,13 +243,13 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
250
243
  it('should throw ValidationError if ID is missing', async () => {
251
244
  await expect(updateNewsletter()).rejects.toThrow(ValidationError);
252
245
  await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required for update');
253
- expect(mockNewslettersApi.read).not.toHaveBeenCalled();
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
296
  await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required for deletion');
304
- expect(mockNewslettersApi.delete).not.toHaveBeenCalled();
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,13 @@ 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');
276
+ const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
307
277
  error404.response = { status: 404 };
308
278
  api.pages.read.mockRejectedValue(error404);
309
279
 
310
- await expect(updatePage('nonexistent-id', { title: 'Updated' })).rejects.toThrow(
311
- 'Page not found'
312
- );
280
+ const rejection = updatePage('nonexistent-id', { title: 'Updated' });
281
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
282
+ await expect(rejection).rejects.toThrow('Page not found');
313
283
  });
314
284
 
315
285
  it('should preserve updated_at timestamp for conflict resolution', async () => {
@@ -341,12 +311,67 @@ describe('ghostServiceImproved - Pages', () => {
341
311
  'Page validation failed'
342
312
  );
343
313
  });
314
+
315
+ it('should reject past published_at when existing page is scheduled (no status in update)', async () => {
316
+ const pageId = 'page-123';
317
+ const existingPage = {
318
+ id: pageId,
319
+ title: 'Scheduled Page',
320
+ status: 'scheduled',
321
+ published_at: new Date(Date.now() + 86400000).toISOString(),
322
+ updated_at: '2024-01-01T00:00:00.000Z',
323
+ };
324
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
325
+
326
+ api.pages.read.mockResolvedValue(existingPage);
327
+
328
+ await expect(updatePage(pageId, { published_at: pastDate })).rejects.toThrow(
329
+ 'Page validation failed'
330
+ );
331
+ });
332
+
333
+ it('should allow future published_at when existing page is scheduled (no status in update)', async () => {
334
+ const pageId = 'page-123';
335
+ const futureDate = new Date(Date.now() + 172800000).toISOString();
336
+ const existingPage = {
337
+ id: pageId,
338
+ title: 'Scheduled Page',
339
+ status: 'scheduled',
340
+ published_at: new Date(Date.now() + 86400000).toISOString(),
341
+ updated_at: '2024-01-01T00:00:00.000Z',
342
+ };
343
+
344
+ api.pages.read.mockResolvedValue(existingPage);
345
+ api.pages.edit.mockResolvedValue({ ...existingPage, published_at: futureDate });
346
+
347
+ const result = await updatePage(pageId, { published_at: futureDate });
348
+
349
+ expect(result).toBeDefined();
350
+ });
351
+
352
+ it('should allow published_at change when existing page is not scheduled', async () => {
353
+ const pageId = 'page-123';
354
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
355
+ const existingPage = {
356
+ id: pageId,
357
+ title: 'Draft Page',
358
+ status: 'draft',
359
+ updated_at: '2024-01-01T00:00:00.000Z',
360
+ };
361
+
362
+ api.pages.read.mockResolvedValue(existingPage);
363
+ api.pages.edit.mockResolvedValue({ ...existingPage, published_at: pastDate });
364
+
365
+ const result = await updatePage(pageId, { published_at: pastDate });
366
+
367
+ expect(result).toBeDefined();
368
+ });
344
369
  });
345
370
 
346
371
  describe('deletePage', () => {
347
372
  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');
373
+ await expect(deletePage(null)).rejects.toThrow('Page ID is required');
374
+ await expect(deletePage('')).rejects.toThrow('Page ID is required');
350
375
  });
351
376
 
352
377
  it('should delete page successfully', async () => {
@@ -361,11 +386,13 @@ describe('ghostServiceImproved - Pages', () => {
361
386
  });
362
387
 
363
388
  it('should handle page not found (404)', async () => {
364
- const error404 = new Error('Page not found');
389
+ const error404 = new GhostAPIError('pages.delete', 'Page not found', 404);
365
390
  error404.response = { status: 404 };
366
391
  api.pages.delete.mockRejectedValue(error404);
367
392
 
368
- await expect(deletePage('nonexistent-id')).rejects.toThrow('Page not found');
393
+ const rejection = deletePage('nonexistent-id');
394
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
395
+ await expect(rejection).rejects.toThrow('Page not found');
369
396
  });
370
397
  });
371
398
 
@@ -409,11 +436,13 @@ describe('ghostServiceImproved - Pages', () => {
409
436
  });
410
437
 
411
438
  it('should handle page not found (404)', async () => {
412
- const error404 = new Error('Page not found');
439
+ const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
413
440
  error404.response = { status: 404 };
414
441
  api.pages.read.mockRejectedValue(error404);
415
442
 
416
- await expect(getPage('nonexistent-id')).rejects.toThrow('Page not found');
443
+ const rejection = getPage('nonexistent-id');
444
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
445
+ await expect(rejection).rejects.toThrow('Page not found');
417
446
  });
418
447
  });
419
448
 
@@ -1,48 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
3
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
4
5
 
5
- // Mock the Ghost Admin API 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,18 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
124
87
  });
125
88
 
126
89
  it('should throw error when post ID is missing', async () => {
127
- await expect(updatePost(null, { title: 'Updated' })).rejects.toThrow(
128
- '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');
95
+ const error404 = new GhostAPIError('posts.read', 'Post not found', 404);
137
96
  error404.response = { status: 404 };
138
97
  api.posts.read.mockRejectedValue(error404);
139
98
 
140
- await expect(updatePost('nonexistent-id', { title: 'Updated' })).rejects.toThrow(
141
- 'Post not found'
142
- );
99
+ const rejection = updatePost('nonexistent-id', { title: 'Updated' });
100
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
101
+ await expect(rejection).rejects.toThrow('Post not found');
143
102
  });
144
103
 
145
104
  it('should throw ValidationError when updating to scheduled without published_at', async () => {
@@ -177,6 +136,61 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
177
136
  {}
178
137
  );
179
138
  });
139
+
140
+ it('should reject past published_at when existing post is scheduled (no status in update)', async () => {
141
+ const postId = 'post-123';
142
+ const existingPost = {
143
+ id: postId,
144
+ title: 'Scheduled Post',
145
+ status: 'scheduled',
146
+ published_at: new Date(Date.now() + 86400000).toISOString(),
147
+ updated_at: '2024-01-01T00:00:00.000Z',
148
+ };
149
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
150
+
151
+ api.posts.read.mockResolvedValue(existingPost);
152
+
153
+ await expect(updatePost(postId, { published_at: pastDate })).rejects.toThrow(
154
+ 'Post validation failed'
155
+ );
156
+ });
157
+
158
+ it('should allow future published_at when existing post is scheduled (no status in update)', async () => {
159
+ const postId = 'post-123';
160
+ const futureDate = new Date(Date.now() + 172800000).toISOString();
161
+ const existingPost = {
162
+ id: postId,
163
+ title: 'Scheduled Post',
164
+ status: 'scheduled',
165
+ published_at: new Date(Date.now() + 86400000).toISOString(),
166
+ updated_at: '2024-01-01T00:00:00.000Z',
167
+ };
168
+
169
+ api.posts.read.mockResolvedValue(existingPost);
170
+ api.posts.edit.mockResolvedValue({ ...existingPost, published_at: futureDate });
171
+
172
+ const result = await updatePost(postId, { published_at: futureDate });
173
+
174
+ expect(result).toBeDefined();
175
+ });
176
+
177
+ it('should allow published_at change when existing post is not scheduled', async () => {
178
+ const postId = 'post-123';
179
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
180
+ const existingPost = {
181
+ id: postId,
182
+ title: 'Draft Post',
183
+ status: 'draft',
184
+ updated_at: '2024-01-01T00:00:00.000Z',
185
+ };
186
+
187
+ api.posts.read.mockResolvedValue(existingPost);
188
+ api.posts.edit.mockResolvedValue({ ...existingPost, published_at: pastDate });
189
+
190
+ const result = await updatePost(postId, { published_at: pastDate });
191
+
192
+ expect(result).toBeDefined();
193
+ });
180
194
  });
181
195
 
182
196
  describe('validators.validateScheduledStatus', () => {
@@ -1,48 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
3
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
4
5
 
5
- // Mock the Ghost Admin API with tags 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());
@@ -69,6 +31,7 @@ import {
69
31
  api,
70
32
  ghostCircuitBreaker,
71
33
  } from '../ghostServiceImproved.js';
34
+ import { GhostAPIError, NotFoundError } from '../../errors/index.js';
72
35
 
73
36
  describe('ghostServiceImproved - Tags', () => {
74
37
  beforeEach(() => {
@@ -288,12 +251,13 @@ describe('ghostServiceImproved - Tags', () => {
288
251
  });
289
252
 
290
253
  it('should throw not found error when tag does not exist', async () => {
291
- api.tags.read.mockRejectedValue({
292
- response: { status: 404 },
293
- message: 'Tag not found',
294
- });
254
+ const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
255
+ error404.response = { status: 404 };
256
+ api.tags.read.mockRejectedValue(error404);
295
257
 
296
- await expect(getTag('non-existent')).rejects.toThrow();
258
+ const rejection = getTag('non-existent');
259
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
260
+ await expect(rejection).rejects.toThrow('Tag not found');
297
261
  });
298
262
  });
299
263
 
@@ -381,10 +345,9 @@ describe('ghostServiceImproved - Tags', () => {
381
345
  };
382
346
 
383
347
  // First call fails with duplicate error
384
- api.tags.add.mockRejectedValue({
385
- response: { status: 422 },
386
- message: 'Tag already exists',
387
- });
348
+ const error422 = new GhostAPIError('tags.add', 'Tag already exists', 422);
349
+ error422.response = { status: 422, data: { errors: [{ message: 'Tag already exists' }] } };
350
+ api.tags.add.mockRejectedValue(error422);
388
351
 
389
352
  // getTags returns existing tag when called with name filter
390
353
  api.tags.browse.mockResolvedValue([{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }]);
@@ -443,18 +406,17 @@ describe('ghostServiceImproved - Tags', () => {
443
406
  });
444
407
 
445
408
  it('should throw validation error for missing tag ID', async () => {
446
- await expect(updateTag(null, { name: 'Test' })).rejects.toThrow(
447
- 'Tag ID is required for update'
448
- );
409
+ await expect(updateTag(null, { name: 'Test' })).rejects.toThrow('Tag ID is required');
449
410
  });
450
411
 
451
412
  it('should throw not found error if tag does not exist', async () => {
452
- api.tags.read.mockRejectedValue({
453
- response: { status: 404 },
454
- message: 'Tag not found',
455
- });
413
+ const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
414
+ error404.response = { status: 404 };
415
+ api.tags.read.mockRejectedValue(error404);
456
416
 
457
- await expect(updateTag('non-existent', { name: 'Test' })).rejects.toThrow();
417
+ const rejection = updateTag('non-existent', { name: 'Test' });
418
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
419
+ await expect(rejection).rejects.toThrow('Tag not found');
458
420
  });
459
421
  });
460
422
 
@@ -471,16 +433,17 @@ describe('ghostServiceImproved - Tags', () => {
471
433
  });
472
434
 
473
435
  it('should throw validation error for missing tag ID', async () => {
474
- await expect(deleteTag(null)).rejects.toThrow('Tag ID is required for deletion');
436
+ await expect(deleteTag(null)).rejects.toThrow('Tag ID is required');
475
437
  });
476
438
 
477
439
  it('should throw not found error if tag does not exist', async () => {
478
- api.tags.delete.mockRejectedValue({
479
- response: { status: 404 },
480
- message: 'Tag not found',
481
- });
440
+ const error404 = new GhostAPIError('tags.delete', 'Tag not found', 404);
441
+ error404.response = { status: 404 };
442
+ api.tags.delete.mockRejectedValue(error404);
482
443
 
483
- await expect(deleteTag('non-existent')).rejects.toThrow();
444
+ const rejection = deleteTag('non-existent');
445
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
446
+ await expect(rejection).rejects.toThrow('Tag not found');
484
447
  });
485
448
  });
486
449
  });
@@ -1,55 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
3
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
4
5
 
5
- // Mock the Ghost Admin API with tiers 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
- tiers: {
38
- add: vi.fn(),
39
- browse: vi.fn(),
40
- read: vi.fn(),
41
- edit: vi.fn(),
42
- delete: vi.fn(),
43
- },
44
- site: {
45
- read: vi.fn(),
46
- },
47
- images: {
48
- upload: vi.fn(),
49
- },
50
- };
51
- }),
52
- }));
6
+ // Mock the Ghost Admin API using shared mock factory
7
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
53
8
 
54
9
  // Mock dotenv
55
10
  vi.mock('dotenv', () => mockDotenv());
@@ -57,7 +57,7 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
57
57
  // Main execution function
58
58
  const executeRequest = async () => {
59
59
  try {
60
- console.error(`Executing Ghost API request: ${operation}`);
60
+ logger.info('Executing Ghost API request', { operation });
61
61
 
62
62
  let result;
63
63
 
@@ -81,7 +81,7 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
81
81
  result = await api[resource][action](data);
82
82
  }
83
83
 
84
- console.error(`Successfully executed Ghost API request: ${operation}`);
84
+ logger.info('Successfully executed Ghost API request', { operation });
85
85
  return result;
86
86
  } catch (error) {
87
87
  // Transform Ghost API errors into our error types
@@ -99,17 +99,21 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
99
99
  return await retryWithBackoff(wrappedExecute, {
100
100
  maxAttempts: maxRetries,
101
101
  onRetry: (attempt, _error) => {
102
- console.error(`Retrying ${operation} (attempt ${attempt}/${maxRetries})`);
102
+ logger.info('Retrying Ghost API request', { operation, attempt, maxRetries });
103
103
 
104
104
  // Log circuit breaker state if relevant
105
105
  if (useCircuitBreaker) {
106
106
  const state = ghostCircuitBreaker.getState();
107
- console.error(`Circuit breaker state:`, state);
107
+ logger.info('Circuit breaker state', { operation, state });
108
108
  }
109
109
  },
110
110
  });
111
111
  } catch (error) {
112
- console.error(`Failed to execute ${operation} after ${maxRetries} attempts:`, error.message);
112
+ logger.error('Failed to execute Ghost API request', {
113
+ operation,
114
+ maxRetries,
115
+ error: error.message,
116
+ });
113
117
  throw error;
114
118
  }
115
119
  };
@@ -310,12 +314,7 @@ async function deleteResource(resource, id, label) {
310
314
  */
311
315
 
312
316
  export async function getSiteInfo() {
313
- try {
314
- return await handleApiRequest('site', 'read');
315
- } catch (error) {
316
- console.error('Failed to get site info:', error);
317
- throw error;
318
- }
317
+ return handleApiRequest('site', 'read');
319
318
  }
320
319
 
321
320
  export async function createPost(postData, options = { source: 'html' }) {
@@ -348,9 +347,15 @@ export async function updatePost(postId, updateData, options = {}) {
348
347
  throw new ValidationError('Post ID is required for update');
349
348
  }
350
349
 
351
- // Validate scheduled status if status is being updated
352
- if (updateData.status) {
353
- validators.validateScheduledStatus(updateData, 'Post');
350
+ // Validate scheduled status when status or published_at is being updated
351
+ if (updateData.status || updateData.published_at) {
352
+ let validationData = updateData;
353
+ // When only published_at changes, fetch existing status to check if post is scheduled
354
+ if (!updateData.status && updateData.published_at) {
355
+ const existing = await readResource('posts', postId, 'Post');
356
+ validationData = { ...updateData, status: existing.status };
357
+ }
358
+ validators.validateScheduledStatus(validationData, 'Post');
354
359
  }
355
360
 
356
361
  return updateWithOCC('posts', postId, updateData, options, 'Post');
@@ -371,12 +376,7 @@ export async function getPosts(options = {}) {
371
376
  ...options,
372
377
  };
373
378
 
374
- try {
375
- return await handleApiRequest('posts', 'browse', {}, defaultOptions);
376
- } catch (error) {
377
- console.error('Failed to get posts:', error);
378
- throw error;
379
- }
379
+ return handleApiRequest('posts', 'browse', {}, defaultOptions);
380
380
  }
381
381
 
382
382
  export async function searchPosts(query, options = {}) {
@@ -402,12 +402,7 @@ export async function searchPosts(query, options = {}) {
402
402
  filter: filterParts.join('+'),
403
403
  };
404
404
 
405
- try {
406
- return await handleApiRequest('posts', 'browse', {}, searchOptions);
407
- } catch (error) {
408
- console.error('Failed to search posts:', error);
409
- throw error;
410
- }
405
+ return handleApiRequest('posts', 'browse', {}, searchOptions);
411
406
  }
412
407
 
413
408
  /**
@@ -446,9 +441,15 @@ export async function updatePage(pageId, updateData, options = {}) {
446
441
 
447
442
  // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
448
443
 
449
- // Validate scheduled status if status is being updated
450
- if (updateData.status) {
451
- validators.validateScheduledStatus(updateData, 'Page');
444
+ // Validate scheduled status when status or published_at is being updated
445
+ if (updateData.status || updateData.published_at) {
446
+ let validationData = updateData;
447
+ // When only published_at changes, fetch existing status to check if page is scheduled
448
+ if (!updateData.status && updateData.published_at) {
449
+ const existing = await readResource('pages', pageId, 'Page');
450
+ validationData = { ...updateData, status: existing.status };
451
+ }
452
+ validators.validateScheduledStatus(validationData, 'Page');
452
453
  }
453
454
 
454
455
  return updateWithOCC('pages', pageId, updateData, options, 'Page');
@@ -469,12 +470,7 @@ export async function getPages(options = {}) {
469
470
  ...options,
470
471
  };
471
472
 
472
- try {
473
- return await handleApiRequest('pages', 'browse', {}, defaultOptions);
474
- } catch (error) {
475
- console.error('Failed to get pages:', error);
476
- throw error;
477
- }
473
+ return handleApiRequest('pages', 'browse', {}, defaultOptions);
478
474
  }
479
475
 
480
476
  export async function searchPages(query, options = {}) {
@@ -500,12 +496,7 @@ export async function searchPages(query, options = {}) {
500
496
  filter: filterParts.join('+'),
501
497
  };
502
498
 
503
- try {
504
- return await handleApiRequest('pages', 'browse', {}, searchOptions);
505
- } catch (error) {
506
- console.error('Failed to search pages:', error);
507
- throw error;
508
- }
499
+ return handleApiRequest('pages', 'browse', {}, searchOptions);
509
500
  }
510
501
 
511
502
  export async function uploadImage(imagePath) {
@@ -557,21 +548,16 @@ export async function createTag(tagData) {
557
548
  }
558
549
 
559
550
  export async function getTags(options = {}) {
560
- try {
561
- const tags = await handleApiRequest(
562
- 'tags',
563
- 'browse',
564
- {},
565
- {
566
- limit: 15,
567
- ...options,
568
- }
569
- );
570
- return tags || [];
571
- } catch (error) {
572
- logger.error('Failed to get tags', { error: error.message });
573
- throw error;
574
- }
551
+ const tags = await handleApiRequest(
552
+ 'tags',
553
+ 'browse',
554
+ {},
555
+ {
556
+ limit: 15,
557
+ ...options,
558
+ }
559
+ );
560
+ return tags || [];
575
561
  }
576
562
 
577
563
  export async function getTag(tagId, options = {}) {
@@ -694,13 +680,8 @@ export async function getMembers(options = {}) {
694
680
  ...options,
695
681
  };
696
682
 
697
- try {
698
- const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
699
- return members || [];
700
- } catch (error) {
701
- console.error('Failed to get members:', error);
702
- throw error;
703
- }
683
+ const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
684
+ return members || [];
704
685
  }
705
686
 
706
687
  /**
@@ -764,13 +745,8 @@ export async function searchMembers(query, options = {}) {
764
745
  // Ghost uses ~ for contains/like matching
765
746
  const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
766
747
 
767
- try {
768
- const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
769
- return members || [];
770
- } catch (error) {
771
- console.error('Failed to search members:', error);
772
- throw error;
773
- }
748
+ const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
749
+ return members || [];
774
750
  }
775
751
 
776
752
  /**
@@ -783,13 +759,8 @@ export async function getNewsletters(options = {}) {
783
759
  ...options,
784
760
  };
785
761
 
786
- try {
787
- const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
788
- return newsletters || [];
789
- } catch (error) {
790
- console.error('Failed to get newsletters:', error);
791
- throw error;
792
- }
762
+ const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
763
+ return newsletters || [];
793
764
  }
794
765
 
795
766
  export async function getNewsletter(newsletterId) {
@@ -905,13 +876,8 @@ export async function getTiers(options = {}) {
905
876
  ...options,
906
877
  };
907
878
 
908
- try {
909
- const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
910
- return tiers || [];
911
- } catch (error) {
912
- console.error('Failed to get tiers:', error);
913
- throw error;
914
- }
879
+ const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
880
+ return tiers || [];
915
881
  }
916
882
 
917
883
  /**