@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 +1 -1
- package/src/__tests__/helpers/mockGhostApi.js +36 -0
- package/src/services/__tests__/ghostServiceImproved.members.test.js +24 -60
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +56 -63
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -48
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +65 -51
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +27 -64
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -48
- package/src/services/ghostServiceImproved.js +50 -84
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
|
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
|
);
|
|
@@ -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(
|
|
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
296
|
await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required for deletion');
|
|
304
|
-
expect(
|
|
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,13 @@ describe('ghostServiceImproved - Pages', () => {
|
|
|
303
273
|
});
|
|
304
274
|
|
|
305
275
|
it('should handle page not found (404)', async () => {
|
|
306
|
-
const error404 = new
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
349
|
-
await expect(deletePage('')).rejects.toThrow('Page ID is required
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
logger.info('Circuit breaker state', { operation, state });
|
|
108
108
|
}
|
|
109
109
|
},
|
|
110
110
|
});
|
|
111
111
|
} catch (error) {
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
352
|
-
if (updateData.status) {
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
450
|
-
if (updateData.status) {
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
909
|
-
|
|
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
|
/**
|