@jgardner04/ghost-mcp-server 1.13.4 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +152 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +647 -1203
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/ghostServiceImproved.js +76 -915
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +54 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pageService.js +2 -2
- 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
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/imageInputResolver.js +127 -0
|
@@ -41,8 +41,8 @@ vi.mock('../../services/imageProcessingService.js', () => ({
|
|
|
41
41
|
processImage: vi.fn().mockResolvedValue('/tmp/processed-image.jpg'),
|
|
42
42
|
}));
|
|
43
43
|
|
|
44
|
-
// Mock the
|
|
45
|
-
vi.mock('../../services/
|
|
44
|
+
// Mock the image upload service (now in images.js, re-exported via ghostServiceImproved.js)
|
|
45
|
+
vi.mock('../../services/images.js', () => ({
|
|
46
46
|
uploadImage: vi.fn().mockResolvedValue({ url: 'https://ghost.com/image.jpg' }),
|
|
47
47
|
}));
|
|
48
48
|
|
|
@@ -336,12 +336,12 @@ describe('Common Schemas', () => {
|
|
|
336
336
|
describe('featureImageAltSchema', () => {
|
|
337
337
|
it('should accept valid alt text', () => {
|
|
338
338
|
expect(() => featureImageAltSchema.parse('Image description')).not.toThrow();
|
|
339
|
-
expect(() => featureImageAltSchema.parse('A'.repeat(
|
|
339
|
+
expect(() => featureImageAltSchema.parse('A'.repeat(191))).not.toThrow();
|
|
340
340
|
expect(() => featureImageAltSchema.parse(undefined)).not.toThrow();
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
-
it('should reject too long alt text', () => {
|
|
344
|
-
expect(() => featureImageAltSchema.parse('A'.repeat(
|
|
343
|
+
it('should reject too long alt text (>191, matches Ghost varchar(191))', () => {
|
|
344
|
+
expect(() => featureImageAltSchema.parse('A'.repeat(192))).toThrow();
|
|
345
345
|
});
|
|
346
346
|
});
|
|
347
347
|
|
|
@@ -115,16 +115,25 @@ describe('Page Schemas', () => {
|
|
|
115
115
|
expect(() => createPageSchema.parse(invalidPage)).toThrow();
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
it('should reject page with too long feature_image_caption', () => {
|
|
118
|
+
it('should reject page with too long feature_image_caption (>5000)', () => {
|
|
119
119
|
const invalidPage = {
|
|
120
120
|
title: 'Page',
|
|
121
121
|
html: '<p>Content</p>',
|
|
122
|
-
feature_image_caption: 'A'.repeat(
|
|
122
|
+
feature_image_caption: 'A'.repeat(5001),
|
|
123
123
|
};
|
|
124
124
|
|
|
125
125
|
expect(() => createPageSchema.parse(invalidPage)).toThrow();
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
it('should accept a 5000-char feature_image_caption', () => {
|
|
129
|
+
const page = {
|
|
130
|
+
title: 'Page',
|
|
131
|
+
html: '<p>Content</p>',
|
|
132
|
+
feature_image_caption: 'A'.repeat(5000),
|
|
133
|
+
};
|
|
134
|
+
expect(() => createPageSchema.parse(page)).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
128
137
|
it('should reject page with too long og_title', () => {
|
|
129
138
|
const invalidPage = {
|
|
130
139
|
title: 'Page',
|
package/src/schemas/common.js
CHANGED
|
@@ -205,11 +205,12 @@ export const featureImageSchema = z.string().url('Invalid feature image URL').op
|
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
207
|
* Feature image alt text validation schema
|
|
208
|
-
* Optional alt text for accessibility
|
|
208
|
+
* Optional alt text for accessibility. Ghost's posts.feature_image_alt
|
|
209
|
+
* column is varchar(191); anything longer is rejected server-side.
|
|
209
210
|
*/
|
|
210
211
|
export const featureImageAltSchema = z
|
|
211
212
|
.string()
|
|
212
|
-
.max(
|
|
213
|
+
.max(191, 'Feature image alt text cannot exceed 191 characters')
|
|
213
214
|
.optional();
|
|
214
215
|
|
|
215
216
|
/**
|
|
@@ -44,7 +44,7 @@ export const createPageSchema = z.object({
|
|
|
44
44
|
featured: featuredSchema,
|
|
45
45
|
feature_image: featureImageSchema,
|
|
46
46
|
feature_image_alt: featureImageAltSchema,
|
|
47
|
-
feature_image_caption: z.string().max(
|
|
47
|
+
feature_image_caption: z.string().max(5000, 'Caption cannot exceed 5000 characters').optional(),
|
|
48
48
|
excerpt: excerptSchema,
|
|
49
49
|
custom_excerpt: customExcerptSchema,
|
|
50
50
|
meta_title: metaTitleSchema,
|
|
@@ -42,7 +42,7 @@ export const createPostSchema = z.object({
|
|
|
42
42
|
featured: featuredSchema,
|
|
43
43
|
feature_image: featureImageSchema,
|
|
44
44
|
feature_image_alt: featureImageAltSchema,
|
|
45
|
-
feature_image_caption: z.string().max(
|
|
45
|
+
feature_image_caption: z.string().max(5000, 'Caption cannot exceed 5000 characters').optional(),
|
|
46
46
|
excerpt: excerptSchema,
|
|
47
47
|
custom_excerpt: customExcerptSchema,
|
|
48
48
|
meta_title: metaTitleSchema,
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
3
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
4
|
+
import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
|
|
5
|
+
|
|
6
|
+
// Mock the Ghost Admin API using shared mock factory
|
|
7
|
+
vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
|
|
8
|
+
|
|
9
|
+
// Mock dotenv
|
|
10
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
11
|
+
|
|
12
|
+
// Mock logger
|
|
13
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
14
|
+
createContextLogger: createMockContextLogger(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock fs for validators
|
|
18
|
+
vi.mock('fs/promises', () => ({
|
|
19
|
+
default: {
|
|
20
|
+
access: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { createResourceService } from '../createResourceService.js';
|
|
25
|
+
import { api, ghostCircuitBreaker } from '../ghostApiClient.js';
|
|
26
|
+
import { GhostAPIError, ValidationError, NotFoundError } from '../../errors/index.js';
|
|
27
|
+
|
|
28
|
+
describe('createResourceService', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
// Reset circuit breaker state between tests so error tests don't trip it open
|
|
32
|
+
ghostCircuitBreaker.state = 'CLOSED';
|
|
33
|
+
ghostCircuitBreaker.failureCount = 0;
|
|
34
|
+
ghostCircuitBreaker.lastFailureTime = null;
|
|
35
|
+
ghostCircuitBreaker.nextAttempt = null;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('create', () => {
|
|
39
|
+
it('should create a resource via handleApiRequest', async () => {
|
|
40
|
+
const service = createResourceService({
|
|
41
|
+
resource: 'posts',
|
|
42
|
+
label: 'Post',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const data = { title: 'Test Post', html: '<p>Hello</p>' };
|
|
46
|
+
const expected = { id: '1', ...data };
|
|
47
|
+
api.posts.add.mockResolvedValue(expected);
|
|
48
|
+
|
|
49
|
+
const result = await service.create(data);
|
|
50
|
+
|
|
51
|
+
expect(result).toEqual(expected);
|
|
52
|
+
expect(api.posts.add).toHaveBeenCalledWith(data, {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should merge createDefaults into data', async () => {
|
|
56
|
+
const service = createResourceService({
|
|
57
|
+
resource: 'posts',
|
|
58
|
+
label: 'Post',
|
|
59
|
+
createDefaults: { status: 'draft' },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const data = { title: 'Test Post', html: '<p>Hello</p>' };
|
|
63
|
+
api.posts.add.mockResolvedValue({ id: '1', ...data, status: 'draft' });
|
|
64
|
+
|
|
65
|
+
await service.create(data);
|
|
66
|
+
|
|
67
|
+
expect(api.posts.add).toHaveBeenCalledWith(
|
|
68
|
+
{ status: 'draft', title: 'Test Post', html: '<p>Hello</p>' },
|
|
69
|
+
{}
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should allow data to override createDefaults', async () => {
|
|
74
|
+
const service = createResourceService({
|
|
75
|
+
resource: 'posts',
|
|
76
|
+
label: 'Post',
|
|
77
|
+
createDefaults: { status: 'draft' },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const data = { title: 'Test', html: '<p>Hi</p>', status: 'published' };
|
|
81
|
+
api.posts.add.mockResolvedValue({ id: '1', ...data });
|
|
82
|
+
|
|
83
|
+
await service.create(data);
|
|
84
|
+
|
|
85
|
+
expect(api.posts.add).toHaveBeenCalledWith(
|
|
86
|
+
expect.objectContaining({ status: 'published' }),
|
|
87
|
+
{}
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should pass createOptions to the API call', async () => {
|
|
92
|
+
const service = createResourceService({
|
|
93
|
+
resource: 'posts',
|
|
94
|
+
label: 'Post',
|
|
95
|
+
createOptions: { source: 'html' },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const data = { title: 'Test' };
|
|
99
|
+
api.posts.add.mockResolvedValue({ id: '1', ...data });
|
|
100
|
+
|
|
101
|
+
await service.create(data);
|
|
102
|
+
|
|
103
|
+
expect(api.posts.add).toHaveBeenCalledWith(data, { source: 'html' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should merge caller options with createOptions', async () => {
|
|
107
|
+
const service = createResourceService({
|
|
108
|
+
resource: 'posts',
|
|
109
|
+
label: 'Post',
|
|
110
|
+
createOptions: { source: 'html' },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const data = { title: 'Test' };
|
|
114
|
+
api.posts.add.mockResolvedValue({ id: '1', ...data });
|
|
115
|
+
|
|
116
|
+
await service.create(data, { formats: 'mobiledoc' });
|
|
117
|
+
|
|
118
|
+
expect(api.posts.add).toHaveBeenCalledWith(data, { source: 'html', formats: 'mobiledoc' });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should call validateCreate before creating', async () => {
|
|
122
|
+
const validateCreate = vi.fn();
|
|
123
|
+
const service = createResourceService({
|
|
124
|
+
resource: 'posts',
|
|
125
|
+
label: 'Post',
|
|
126
|
+
validateCreate,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const data = { title: 'Test' };
|
|
130
|
+
api.posts.add.mockResolvedValue({ id: '1', ...data });
|
|
131
|
+
|
|
132
|
+
await service.create(data);
|
|
133
|
+
|
|
134
|
+
expect(validateCreate).toHaveBeenCalledWith(data);
|
|
135
|
+
expect(validateCreate).toHaveBeenCalledBefore(api.posts.add);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should support async validateCreate', async () => {
|
|
139
|
+
const validateCreate = vi.fn().mockResolvedValue(undefined);
|
|
140
|
+
const service = createResourceService({
|
|
141
|
+
resource: 'posts',
|
|
142
|
+
label: 'Post',
|
|
143
|
+
validateCreate,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const data = { title: 'Test' };
|
|
147
|
+
api.posts.add.mockResolvedValue({ id: '1', ...data });
|
|
148
|
+
|
|
149
|
+
await service.create(data);
|
|
150
|
+
|
|
151
|
+
expect(validateCreate).toHaveBeenCalledWith(data);
|
|
152
|
+
expect(api.posts.add).toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should not call API if validateCreate throws', async () => {
|
|
156
|
+
const validateCreate = vi.fn(() => {
|
|
157
|
+
throw new ValidationError('Invalid data');
|
|
158
|
+
});
|
|
159
|
+
const service = createResourceService({
|
|
160
|
+
resource: 'posts',
|
|
161
|
+
label: 'Post',
|
|
162
|
+
validateCreate,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await expect(service.create({ title: '' })).rejects.toThrow(ValidationError);
|
|
166
|
+
expect(api.posts.add).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should convert 422 errors to ValidationError', async () => {
|
|
170
|
+
const service = createResourceService({
|
|
171
|
+
resource: 'posts',
|
|
172
|
+
label: 'Post',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const ghostError = new Error('Validation failed');
|
|
176
|
+
ghostError.response = { status: 422 };
|
|
177
|
+
api.posts.add.mockRejectedValue(ghostError);
|
|
178
|
+
|
|
179
|
+
await expect(service.create({ title: 'Test' })).rejects.toThrow(ValidationError);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should re-throw non-422 errors', async () => {
|
|
183
|
+
const service = createResourceService({
|
|
184
|
+
resource: 'posts',
|
|
185
|
+
label: 'Post',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const error = new Error('Server Error');
|
|
189
|
+
error.response = { status: 500 };
|
|
190
|
+
api.posts.add.mockRejectedValue(error);
|
|
191
|
+
|
|
192
|
+
await expect(service.create({ title: 'Test' })).rejects.toThrow(GhostAPIError);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('update', () => {
|
|
197
|
+
it('should update a resource with OCC', async () => {
|
|
198
|
+
const service = createResourceService({
|
|
199
|
+
resource: 'posts',
|
|
200
|
+
label: 'Post',
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const existing = { id: 'post-1', title: 'Old', updated_at: '2024-01-01T00:00:00.000Z' };
|
|
204
|
+
const updated = { ...existing, title: 'New' };
|
|
205
|
+
|
|
206
|
+
api.posts.read.mockResolvedValue(existing);
|
|
207
|
+
api.posts.edit.mockResolvedValue(updated);
|
|
208
|
+
|
|
209
|
+
const result = await service.update('post-1', { title: 'New' });
|
|
210
|
+
|
|
211
|
+
expect(result).toEqual(updated);
|
|
212
|
+
expect(api.posts.read).toHaveBeenCalledWith({}, { id: 'post-1' });
|
|
213
|
+
expect(api.posts.edit).toHaveBeenCalledWith(
|
|
214
|
+
{ id: 'post-1', title: 'New', updated_at: '2024-01-01T00:00:00.000Z' },
|
|
215
|
+
{}
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should throw ValidationError if ID is missing', async () => {
|
|
220
|
+
const service = createResourceService({
|
|
221
|
+
resource: 'posts',
|
|
222
|
+
label: 'Post',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await expect(service.update(undefined, { title: 'New' })).rejects.toThrow(ValidationError);
|
|
226
|
+
await expect(service.update('', { title: 'New' })).rejects.toThrow('Post ID is required');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should call validateUpdate before updating', async () => {
|
|
230
|
+
const validateUpdate = vi.fn();
|
|
231
|
+
const service = createResourceService({
|
|
232
|
+
resource: 'posts',
|
|
233
|
+
label: 'Post',
|
|
234
|
+
validateUpdate,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const existing = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
|
|
238
|
+
api.posts.read.mockResolvedValue(existing);
|
|
239
|
+
api.posts.edit.mockResolvedValue(existing);
|
|
240
|
+
|
|
241
|
+
await service.update('post-1', { title: 'New' });
|
|
242
|
+
|
|
243
|
+
expect(validateUpdate).toHaveBeenCalledWith('post-1', { title: 'New' });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should support async validateUpdate', async () => {
|
|
247
|
+
const validateUpdate = vi.fn().mockResolvedValue(undefined);
|
|
248
|
+
const service = createResourceService({
|
|
249
|
+
resource: 'posts',
|
|
250
|
+
label: 'Post',
|
|
251
|
+
validateUpdate,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const existing = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
|
|
255
|
+
api.posts.read.mockResolvedValue(existing);
|
|
256
|
+
api.posts.edit.mockResolvedValue(existing);
|
|
257
|
+
|
|
258
|
+
await service.update('post-1', { title: 'New' });
|
|
259
|
+
|
|
260
|
+
expect(validateUpdate).toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should catch 422 on update when catch422OnUpdate is true', async () => {
|
|
264
|
+
const service = createResourceService({
|
|
265
|
+
resource: 'newsletters',
|
|
266
|
+
label: 'Newsletter',
|
|
267
|
+
catch422OnUpdate: true,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const existing = { id: 'nl-1', updated_at: '2024-01-01T00:00:00.000Z' };
|
|
271
|
+
api.newsletters.read.mockResolvedValue(existing);
|
|
272
|
+
|
|
273
|
+
const ghostError = new Error('Name already exists');
|
|
274
|
+
ghostError.response = { status: 422 };
|
|
275
|
+
api.newsletters.edit.mockRejectedValue(ghostError);
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
await service.update('nl-1', { name: 'Dupe' });
|
|
279
|
+
expect.fail('Expected ValidationError to be thrown');
|
|
280
|
+
} catch (error) {
|
|
281
|
+
expect(error).toBeInstanceOf(ValidationError);
|
|
282
|
+
expect(error.message).toBe('Newsletter update failed');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should not catch 422 on update when catch422OnUpdate is false', async () => {
|
|
287
|
+
const service = createResourceService({
|
|
288
|
+
resource: 'posts',
|
|
289
|
+
label: 'Post',
|
|
290
|
+
catch422OnUpdate: false,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const existing = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
|
|
294
|
+
api.posts.read.mockResolvedValue(existing);
|
|
295
|
+
|
|
296
|
+
const ghostError = new Error('Something');
|
|
297
|
+
ghostError.response = { status: 422 };
|
|
298
|
+
api.posts.edit.mockRejectedValue(ghostError);
|
|
299
|
+
|
|
300
|
+
await expect(service.update('post-1', { title: 'New' })).rejects.toThrow(GhostAPIError);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('remove', () => {
|
|
305
|
+
it('should delete a resource by ID', async () => {
|
|
306
|
+
const service = createResourceService({
|
|
307
|
+
resource: 'posts',
|
|
308
|
+
label: 'Post',
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
api.posts.delete.mockResolvedValue({ id: 'post-1' });
|
|
312
|
+
|
|
313
|
+
const result = await service.remove('post-1');
|
|
314
|
+
|
|
315
|
+
expect(result).toEqual({ id: 'post-1' });
|
|
316
|
+
expect(api.posts.delete).toHaveBeenCalledWith('post-1', {});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should throw ValidationError if ID is missing', async () => {
|
|
320
|
+
const service = createResourceService({
|
|
321
|
+
resource: 'posts',
|
|
322
|
+
label: 'Post',
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await expect(service.remove()).rejects.toThrow(ValidationError);
|
|
326
|
+
await expect(service.remove()).rejects.toThrow('Post ID is required');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should throw NotFoundError when resource does not exist', async () => {
|
|
330
|
+
const service = createResourceService({
|
|
331
|
+
resource: 'posts',
|
|
332
|
+
label: 'Post',
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const ghostError = new Error('Not found');
|
|
336
|
+
ghostError.response = { status: 404 };
|
|
337
|
+
api.posts.delete.mockRejectedValue(ghostError);
|
|
338
|
+
|
|
339
|
+
await expect(service.remove('nonexistent')).rejects.toThrow(NotFoundError);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('getOne', () => {
|
|
344
|
+
it('should retrieve a resource by ID', async () => {
|
|
345
|
+
const service = createResourceService({
|
|
346
|
+
resource: 'posts',
|
|
347
|
+
label: 'Post',
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const expected = { id: 'post-1', title: 'Test' };
|
|
351
|
+
api.posts.read.mockResolvedValue(expected);
|
|
352
|
+
|
|
353
|
+
const result = await service.getOne('post-1');
|
|
354
|
+
|
|
355
|
+
expect(result).toEqual(expected);
|
|
356
|
+
expect(api.posts.read).toHaveBeenCalledWith({}, { id: 'post-1' });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should pass options to read', async () => {
|
|
360
|
+
const service = createResourceService({
|
|
361
|
+
resource: 'posts',
|
|
362
|
+
label: 'Post',
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
api.posts.read.mockResolvedValue({ id: 'post-1' });
|
|
366
|
+
|
|
367
|
+
await service.getOne('post-1', { include: 'tags' });
|
|
368
|
+
|
|
369
|
+
expect(api.posts.read).toHaveBeenCalledWith({ include: 'tags' }, { id: 'post-1' });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should throw ValidationError if ID is missing', async () => {
|
|
373
|
+
const service = createResourceService({
|
|
374
|
+
resource: 'posts',
|
|
375
|
+
label: 'Post',
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await expect(service.getOne()).rejects.toThrow(ValidationError);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should throw NotFoundError when resource does not exist', async () => {
|
|
382
|
+
const service = createResourceService({
|
|
383
|
+
resource: 'posts',
|
|
384
|
+
label: 'Post',
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const ghostError = new Error('Not found');
|
|
388
|
+
ghostError.response = { status: 404 };
|
|
389
|
+
api.posts.read.mockRejectedValue(ghostError);
|
|
390
|
+
|
|
391
|
+
await expect(service.getOne('nonexistent')).rejects.toThrow(NotFoundError);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('getList', () => {
|
|
396
|
+
it('should list resources with defaults', async () => {
|
|
397
|
+
const service = createResourceService({
|
|
398
|
+
resource: 'posts',
|
|
399
|
+
label: 'Post',
|
|
400
|
+
listDefaults: { limit: 15, include: 'tags,authors' },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const expected = [{ id: '1' }, { id: '2' }];
|
|
404
|
+
api.posts.browse.mockResolvedValue(expected);
|
|
405
|
+
|
|
406
|
+
const result = await service.getList();
|
|
407
|
+
|
|
408
|
+
expect(result).toEqual(expected);
|
|
409
|
+
expect(api.posts.browse).toHaveBeenCalledWith({ limit: 15, include: 'tags,authors' }, {});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should allow overriding defaults', async () => {
|
|
413
|
+
const service = createResourceService({
|
|
414
|
+
resource: 'posts',
|
|
415
|
+
label: 'Post',
|
|
416
|
+
listDefaults: { limit: 15, include: 'tags,authors' },
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
api.posts.browse.mockResolvedValue([]);
|
|
420
|
+
|
|
421
|
+
await service.getList({ limit: 5, filter: 'status:published' });
|
|
422
|
+
|
|
423
|
+
expect(api.posts.browse).toHaveBeenCalledWith(
|
|
424
|
+
{ limit: 5, include: 'tags,authors', filter: 'status:published' },
|
|
425
|
+
{}
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should return empty array when API returns null/undefined', async () => {
|
|
430
|
+
const service = createResourceService({
|
|
431
|
+
resource: 'posts',
|
|
432
|
+
label: 'Post',
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
api.posts.browse.mockResolvedValue(null);
|
|
436
|
+
|
|
437
|
+
const result = await service.getList();
|
|
438
|
+
|
|
439
|
+
expect(result).toEqual([]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should return empty array when API returns empty array', async () => {
|
|
443
|
+
const service = createResourceService({
|
|
444
|
+
resource: 'posts',
|
|
445
|
+
label: 'Post',
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
api.posts.browse.mockResolvedValue([]);
|
|
449
|
+
|
|
450
|
+
const result = await service.getList();
|
|
451
|
+
|
|
452
|
+
expect(result).toEqual([]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should use default limit of 15 when no listDefaults provided', async () => {
|
|
456
|
+
const service = createResourceService({
|
|
457
|
+
resource: 'tags',
|
|
458
|
+
label: 'Tag',
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
api.tags.browse.mockResolvedValue([]);
|
|
462
|
+
|
|
463
|
+
await service.getList();
|
|
464
|
+
|
|
465
|
+
expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15 }, {});
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
createTag,
|
|
21
21
|
getTags,
|
|
22
22
|
getSiteInfo,
|
|
23
|
-
uploadImage,
|
|
24
23
|
handleApiRequest,
|
|
25
24
|
api,
|
|
26
25
|
} from '../ghostService.js';
|
|
@@ -209,24 +208,6 @@ describe('ghostService', () => {
|
|
|
209
208
|
});
|
|
210
209
|
});
|
|
211
210
|
|
|
212
|
-
describe('uploadImage', () => {
|
|
213
|
-
it('should throw error when image path is missing', async () => {
|
|
214
|
-
await expect(uploadImage()).rejects.toThrow('Image path is required for upload');
|
|
215
|
-
await expect(uploadImage('')).rejects.toThrow('Image path is required for upload');
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('should successfully upload image with valid path', async () => {
|
|
219
|
-
const imagePath = '/path/to/image.jpg';
|
|
220
|
-
const expectedResult = { url: 'https://example.com/uploaded-image.jpg' };
|
|
221
|
-
api.images.upload.mockResolvedValue(expectedResult);
|
|
222
|
-
|
|
223
|
-
const result = await uploadImage(imagePath);
|
|
224
|
-
|
|
225
|
-
expect(result).toEqual(expectedResult);
|
|
226
|
-
expect(api.images.upload).toHaveBeenCalledWith({ file: imagePath });
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
211
|
describe('createPost', () => {
|
|
231
212
|
it('should throw error when title is missing', async () => {
|
|
232
213
|
await expect(createPost({})).rejects.toThrow('Post title is required');
|
|
@@ -185,7 +185,6 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
185
185
|
|
|
186
186
|
it('should throw not found error if member does not exist', async () => {
|
|
187
187
|
const error404 = new GhostAPIError('members.read', 'Member not found', 404);
|
|
188
|
-
error404.response = { status: 404 };
|
|
189
188
|
api.members.read.mockRejectedValue(error404);
|
|
190
189
|
|
|
191
190
|
const rejection = updateMember('non-existent', { name: 'Test' });
|
|
@@ -212,7 +211,6 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
212
211
|
|
|
213
212
|
it('should throw not found error if member does not exist', async () => {
|
|
214
213
|
const error404 = new GhostAPIError('members.delete', 'Member not found', 404);
|
|
215
|
-
error404.response = { status: 404 };
|
|
216
214
|
api.members.delete.mockRejectedValue(error404);
|
|
217
215
|
|
|
218
216
|
const rejection = deleteMember('non-existent');
|
|
@@ -360,7 +358,6 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
360
358
|
|
|
361
359
|
it('should throw not found error when member not found by ID', async () => {
|
|
362
360
|
const error404 = new GhostAPIError('members.read', 'Member not found', 404);
|
|
363
|
-
error404.response = { status: 404 };
|
|
364
361
|
api.members.read.mockRejectedValue(error404);
|
|
365
362
|
|
|
366
363
|
const rejection = getMember({ id: 'non-existent' });
|
|
@@ -242,7 +242,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
242
242
|
|
|
243
243
|
it('should throw ValidationError if ID is missing', async () => {
|
|
244
244
|
await expect(updateNewsletter()).rejects.toThrow(ValidationError);
|
|
245
|
-
await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required
|
|
245
|
+
await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required');
|
|
246
246
|
expect(api.newsletters.read).not.toHaveBeenCalled();
|
|
247
247
|
});
|
|
248
248
|
|
|
@@ -293,7 +293,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
293
293
|
|
|
294
294
|
it('should throw ValidationError if ID is missing', async () => {
|
|
295
295
|
await expect(deleteNewsletter()).rejects.toThrow(ValidationError);
|
|
296
|
-
await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required
|
|
296
|
+
await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required');
|
|
297
297
|
expect(api.newsletters.delete).not.toHaveBeenCalled();
|
|
298
298
|
});
|
|
299
299
|
|
|
@@ -274,7 +274,6 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
274
274
|
|
|
275
275
|
it('should handle page not found (404)', async () => {
|
|
276
276
|
const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
|
|
277
|
-
error404.response = { status: 404 };
|
|
278
277
|
api.pages.read.mockRejectedValue(error404);
|
|
279
278
|
|
|
280
279
|
const rejection = updatePage('nonexistent-id', { title: 'Updated' });
|
|
@@ -387,7 +386,6 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
387
386
|
|
|
388
387
|
it('should handle page not found (404)', async () => {
|
|
389
388
|
const error404 = new GhostAPIError('pages.delete', 'Page not found', 404);
|
|
390
|
-
error404.response = { status: 404 };
|
|
391
389
|
api.pages.delete.mockRejectedValue(error404);
|
|
392
390
|
|
|
393
391
|
const rejection = deletePage('nonexistent-id');
|
|
@@ -437,7 +435,6 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
437
435
|
|
|
438
436
|
it('should handle page not found (404)', async () => {
|
|
439
437
|
const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
|
|
440
|
-
error404.response = { status: 404 };
|
|
441
438
|
api.pages.read.mockRejectedValue(error404);
|
|
442
439
|
|
|
443
440
|
const rejection = getPage('nonexistent-id');
|
|
@@ -93,7 +93,6 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
|
|
|
93
93
|
|
|
94
94
|
it('should handle post not found (404)', async () => {
|
|
95
95
|
const error404 = new GhostAPIError('posts.read', 'Post not found', 404);
|
|
96
|
-
error404.response = { status: 404 };
|
|
97
96
|
api.posts.read.mockRejectedValue(error404);
|
|
98
97
|
|
|
99
98
|
const rejection = updatePost('nonexistent-id', { title: 'Updated' });
|