@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.
- package/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/mockGhostApi.js +36 -0
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +69 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/mcp_server.js +393 -1143
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostServiceImproved.members.test.js +21 -60
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +58 -65
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -51
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +65 -52
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +24 -64
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +6 -53
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostServiceImproved.js +76 -949
- package/src/services/images.js +27 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
|
@@ -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
|
|
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
|
-
//
|
|
8
|
-
vi.mock('
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
46
|
+
api.newsletters.browse.mockResolvedValue(mockNewsletters);
|
|
54
47
|
|
|
55
48
|
const result = await getNewsletters();
|
|
56
49
|
|
|
57
50
|
expect(result).toEqual(mockNewsletters);
|
|
58
|
-
expect(
|
|
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
|
-
|
|
56
|
+
api.newsletters.browse.mockResolvedValue(mockNewsletters);
|
|
64
57
|
|
|
65
58
|
await getNewsletters({ limit: 5 });
|
|
66
59
|
|
|
67
|
-
expect(
|
|
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
|
-
|
|
65
|
+
api.newsletters.browse.mockResolvedValue(mockNewsletters);
|
|
73
66
|
|
|
74
67
|
await getNewsletters({ filter: 'status:active' });
|
|
75
68
|
|
|
76
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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(
|
|
207
|
+
expect(api.newsletters.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
|
|
215
208
|
// Should send ONLY updateData + updated_at, NOT the full existing newsletter
|
|
216
|
-
expect(
|
|
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 =
|
|
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
|
-
|
|
239
|
-
|
|
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(
|
|
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
|
|
253
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
304
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
349
|
-
await expect(deletePage('')).rejects.toThrow('Page ID is required
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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', () => {
|