@jgardner04/ghost-mcp-server 1.5.0 → 1.7.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/package.json +1 -1
- package/src/__tests__/mcp_server_improved.test.js +285 -0
- package/src/mcp_server_improved.js +318 -2
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +306 -0
- package/src/services/__tests__/newsletterService.test.js +217 -0
- package/src/services/ghostServiceImproved.js +138 -3
- package/src/services/newsletterService.js +47 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
3
|
+
|
|
4
|
+
// Mock dotenv before other imports
|
|
5
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
6
|
+
|
|
7
|
+
// Create a mock Ghost Admin API
|
|
8
|
+
vi.mock('@tryghost/admin-api', () => {
|
|
9
|
+
const mockNewslettersApi = {
|
|
10
|
+
browse: vi.fn(),
|
|
11
|
+
read: vi.fn(),
|
|
12
|
+
add: vi.fn(),
|
|
13
|
+
edit: vi.fn(),
|
|
14
|
+
delete: vi.fn(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
default: class {
|
|
19
|
+
constructor() {
|
|
20
|
+
return {
|
|
21
|
+
newsletters: mockNewslettersApi,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
mockNewslettersApi,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Import after mocks are set up
|
|
30
|
+
import {
|
|
31
|
+
getNewsletters,
|
|
32
|
+
getNewsletter,
|
|
33
|
+
createNewsletter,
|
|
34
|
+
updateNewsletter,
|
|
35
|
+
deleteNewsletter,
|
|
36
|
+
} from '../ghostServiceImproved.js';
|
|
37
|
+
import { ValidationError, NotFoundError } from '../../errors/index.js';
|
|
38
|
+
|
|
39
|
+
// Get the mock API
|
|
40
|
+
const { mockNewslettersApi } = await vi.importMock('@tryghost/admin-api');
|
|
41
|
+
|
|
42
|
+
describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('getNewsletters', () => {
|
|
48
|
+
it('should retrieve all newsletters', async () => {
|
|
49
|
+
const mockNewsletters = [
|
|
50
|
+
{ id: '1', name: 'Newsletter 1', slug: 'newsletter-1' },
|
|
51
|
+
{ id: '2', name: 'Newsletter 2', slug: 'newsletter-2' },
|
|
52
|
+
];
|
|
53
|
+
mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
|
|
54
|
+
|
|
55
|
+
const result = await getNewsletters();
|
|
56
|
+
|
|
57
|
+
expect(result).toEqual(mockNewsletters);
|
|
58
|
+
expect(mockNewslettersApi.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should support custom limit', async () => {
|
|
62
|
+
const mockNewsletters = [{ id: '1', name: 'Newsletter 1' }];
|
|
63
|
+
mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
|
|
64
|
+
|
|
65
|
+
await getNewsletters({ limit: 5 });
|
|
66
|
+
|
|
67
|
+
expect(mockNewslettersApi.browse).toHaveBeenCalledWith({ limit: 5 }, {});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should support filter option', async () => {
|
|
71
|
+
const mockNewsletters = [{ id: '1', name: 'Active Newsletter' }];
|
|
72
|
+
mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
|
|
73
|
+
|
|
74
|
+
await getNewsletters({ filter: 'status:active' });
|
|
75
|
+
|
|
76
|
+
expect(mockNewslettersApi.browse).toHaveBeenCalledWith(
|
|
77
|
+
{ limit: 'all', filter: 'status:active' },
|
|
78
|
+
{}
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should handle empty results', async () => {
|
|
83
|
+
mockNewslettersApi.browse.mockResolvedValue([]);
|
|
84
|
+
|
|
85
|
+
const result = await getNewsletters();
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should propagate API errors', async () => {
|
|
91
|
+
mockNewslettersApi.browse.mockRejectedValue(new Error('API Error'));
|
|
92
|
+
|
|
93
|
+
await expect(getNewsletters()).rejects.toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('getNewsletter', () => {
|
|
98
|
+
it('should retrieve a newsletter by ID', async () => {
|
|
99
|
+
const mockNewsletter = { id: 'newsletter-123', name: 'My Newsletter' };
|
|
100
|
+
mockNewslettersApi.read.mockResolvedValue(mockNewsletter);
|
|
101
|
+
|
|
102
|
+
const result = await getNewsletter('newsletter-123');
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual(mockNewsletter);
|
|
105
|
+
expect(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should throw ValidationError if ID is missing', async () => {
|
|
109
|
+
await expect(getNewsletter()).rejects.toThrow(ValidationError);
|
|
110
|
+
await expect(getNewsletter()).rejects.toThrow('Newsletter ID is required');
|
|
111
|
+
expect(mockNewslettersApi.read).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should throw NotFoundError when newsletter does not exist', async () => {
|
|
115
|
+
const ghostError = new Error('Not found');
|
|
116
|
+
ghostError.response = { status: 404 };
|
|
117
|
+
mockNewslettersApi.read.mockRejectedValue(ghostError);
|
|
118
|
+
|
|
119
|
+
await expect(getNewsletter('nonexistent')).rejects.toThrow(NotFoundError);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('createNewsletter', () => {
|
|
124
|
+
it('should create a newsletter with valid data', async () => {
|
|
125
|
+
const newsletterData = {
|
|
126
|
+
name: 'Weekly Newsletter',
|
|
127
|
+
description: 'Our weekly updates',
|
|
128
|
+
};
|
|
129
|
+
const createdNewsletter = { id: '1', ...newsletterData };
|
|
130
|
+
mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
|
|
131
|
+
|
|
132
|
+
const result = await createNewsletter(newsletterData);
|
|
133
|
+
|
|
134
|
+
expect(result).toEqual(createdNewsletter);
|
|
135
|
+
expect(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should create newsletter with sender email', async () => {
|
|
139
|
+
const newsletterData = {
|
|
140
|
+
name: 'Newsletter',
|
|
141
|
+
sender_name: 'John Doe',
|
|
142
|
+
sender_email: 'john@example.com',
|
|
143
|
+
sender_reply_to: 'newsletter',
|
|
144
|
+
};
|
|
145
|
+
const createdNewsletter = { id: '1', ...newsletterData };
|
|
146
|
+
mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
|
|
147
|
+
|
|
148
|
+
const result = await createNewsletter(newsletterData);
|
|
149
|
+
|
|
150
|
+
expect(result).toEqual(createdNewsletter);
|
|
151
|
+
expect(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should create newsletter with display options', async () => {
|
|
155
|
+
const newsletterData = {
|
|
156
|
+
name: 'Newsletter',
|
|
157
|
+
subscribe_on_signup: true,
|
|
158
|
+
show_header_icon: true,
|
|
159
|
+
show_header_title: false,
|
|
160
|
+
};
|
|
161
|
+
const createdNewsletter = { id: '1', ...newsletterData };
|
|
162
|
+
mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
|
|
163
|
+
|
|
164
|
+
const result = await createNewsletter(newsletterData);
|
|
165
|
+
|
|
166
|
+
expect(result).toEqual(createdNewsletter);
|
|
167
|
+
expect(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should throw ValidationError if name is missing', async () => {
|
|
171
|
+
const invalidData = { description: 'No name' };
|
|
172
|
+
|
|
173
|
+
await expect(createNewsletter(invalidData)).rejects.toThrow(ValidationError);
|
|
174
|
+
await expect(createNewsletter(invalidData)).rejects.toThrow('Newsletter validation failed');
|
|
175
|
+
expect(mockNewslettersApi.add).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should throw ValidationError if name is empty', async () => {
|
|
179
|
+
const invalidData = { name: '' };
|
|
180
|
+
|
|
181
|
+
await expect(createNewsletter(invalidData)).rejects.toThrow(ValidationError);
|
|
182
|
+
await expect(createNewsletter(invalidData)).rejects.toThrow('Newsletter validation failed');
|
|
183
|
+
expect(mockNewslettersApi.add).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should handle Ghost API validation errors', async () => {
|
|
187
|
+
const newsletterData = { name: 'Newsletter' };
|
|
188
|
+
const ghostError = new Error('Validation failed');
|
|
189
|
+
ghostError.response = { status: 422 };
|
|
190
|
+
mockNewslettersApi.add.mockRejectedValue(ghostError);
|
|
191
|
+
|
|
192
|
+
await expect(createNewsletter(newsletterData)).rejects.toThrow();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('updateNewsletter', () => {
|
|
197
|
+
it('should update a newsletter successfully', async () => {
|
|
198
|
+
const existingNewsletter = {
|
|
199
|
+
id: 'newsletter-123',
|
|
200
|
+
name: 'Old Name',
|
|
201
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
202
|
+
};
|
|
203
|
+
const updateData = { name: 'New Name' };
|
|
204
|
+
const updatedNewsletter = { ...existingNewsletter, ...updateData };
|
|
205
|
+
|
|
206
|
+
mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
|
|
207
|
+
mockNewslettersApi.edit.mockResolvedValue(updatedNewsletter);
|
|
208
|
+
|
|
209
|
+
const result = await updateNewsletter('newsletter-123', updateData);
|
|
210
|
+
|
|
211
|
+
expect(result).toEqual(updatedNewsletter);
|
|
212
|
+
expect(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
|
|
213
|
+
expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
|
|
214
|
+
{
|
|
215
|
+
...existingNewsletter,
|
|
216
|
+
...updateData,
|
|
217
|
+
},
|
|
218
|
+
{ id: 'newsletter-123' }
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should update newsletter with email settings', async () => {
|
|
223
|
+
const existingNewsletter = {
|
|
224
|
+
id: 'newsletter-123',
|
|
225
|
+
name: 'Newsletter',
|
|
226
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
227
|
+
};
|
|
228
|
+
const updateData = {
|
|
229
|
+
sender_name: 'Updated Sender',
|
|
230
|
+
sender_email: 'updated@example.com',
|
|
231
|
+
subscribe_on_signup: false,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
|
|
235
|
+
mockNewslettersApi.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
|
|
236
|
+
|
|
237
|
+
await updateNewsletter('newsletter-123', updateData);
|
|
238
|
+
|
|
239
|
+
expect(mockNewslettersApi.edit).toHaveBeenCalledWith(expect.objectContaining(updateData), {
|
|
240
|
+
id: 'newsletter-123',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should throw ValidationError if ID is missing', async () => {
|
|
245
|
+
await expect(updateNewsletter()).rejects.toThrow(ValidationError);
|
|
246
|
+
await expect(updateNewsletter()).rejects.toThrow('Newsletter ID is required for update');
|
|
247
|
+
expect(mockNewslettersApi.read).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should throw NotFoundError if newsletter does not exist', async () => {
|
|
251
|
+
const ghostError = new Error('Not found');
|
|
252
|
+
ghostError.response = { status: 404 };
|
|
253
|
+
mockNewslettersApi.read.mockRejectedValue(ghostError);
|
|
254
|
+
|
|
255
|
+
await expect(updateNewsletter('nonexistent', { name: 'New Name' })).rejects.toThrow(
|
|
256
|
+
NotFoundError
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should preserve updated_at from existing newsletter', async () => {
|
|
261
|
+
const existingNewsletter = {
|
|
262
|
+
id: 'newsletter-123',
|
|
263
|
+
name: 'Newsletter',
|
|
264
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
265
|
+
};
|
|
266
|
+
const updateData = { description: 'Updated description' };
|
|
267
|
+
|
|
268
|
+
mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
|
|
269
|
+
mockNewslettersApi.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
|
|
270
|
+
|
|
271
|
+
await updateNewsletter('newsletter-123', updateData);
|
|
272
|
+
|
|
273
|
+
expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
|
|
274
|
+
expect.objectContaining({
|
|
275
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
276
|
+
}),
|
|
277
|
+
{ id: 'newsletter-123' }
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('deleteNewsletter', () => {
|
|
283
|
+
it('should delete a newsletter successfully', async () => {
|
|
284
|
+
mockNewslettersApi.delete.mockResolvedValue({ id: 'newsletter-123' });
|
|
285
|
+
|
|
286
|
+
const result = await deleteNewsletter('newsletter-123');
|
|
287
|
+
|
|
288
|
+
expect(result).toEqual({ id: 'newsletter-123' });
|
|
289
|
+
expect(mockNewslettersApi.delete).toHaveBeenCalledWith('newsletter-123', {});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should throw ValidationError if ID is missing', async () => {
|
|
293
|
+
await expect(deleteNewsletter()).rejects.toThrow(ValidationError);
|
|
294
|
+
await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required for deletion');
|
|
295
|
+
expect(mockNewslettersApi.delete).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should throw NotFoundError if newsletter does not exist', async () => {
|
|
299
|
+
const ghostError = new Error('Not found');
|
|
300
|
+
ghostError.response = { status: 404 };
|
|
301
|
+
mockNewslettersApi.delete.mockRejectedValue(ghostError);
|
|
302
|
+
|
|
303
|
+
await expect(deleteNewsletter('nonexistent')).rejects.toThrow(NotFoundError);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
3
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
4
|
+
|
|
5
|
+
// Mock dotenv
|
|
6
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
7
|
+
|
|
8
|
+
// Mock logger
|
|
9
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
10
|
+
createContextLogger: createMockContextLogger(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock ghostServiceImproved functions
|
|
14
|
+
vi.mock('../ghostServiceImproved.js', () => ({
|
|
15
|
+
createNewsletter: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Import after mocks are set up
|
|
19
|
+
import { createNewsletterService } from '../newsletterService.js';
|
|
20
|
+
import { createNewsletter } from '../ghostServiceImproved.js';
|
|
21
|
+
|
|
22
|
+
describe('newsletterService', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('createNewsletterService - validation', () => {
|
|
28
|
+
it('should accept valid input and create a newsletter', async () => {
|
|
29
|
+
const validInput = {
|
|
30
|
+
name: 'Weekly Newsletter',
|
|
31
|
+
};
|
|
32
|
+
const expectedNewsletter = { id: '1', name: 'Weekly Newsletter', slug: 'weekly-newsletter' };
|
|
33
|
+
createNewsletter.mockResolvedValue(expectedNewsletter);
|
|
34
|
+
|
|
35
|
+
const result = await createNewsletterService(validInput);
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual(expectedNewsletter);
|
|
38
|
+
expect(createNewsletter).toHaveBeenCalledWith(
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
name: 'Weekly Newsletter',
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should reject input with missing name', async () => {
|
|
46
|
+
const invalidInput = {};
|
|
47
|
+
|
|
48
|
+
await expect(createNewsletterService(invalidInput)).rejects.toThrow(
|
|
49
|
+
'Invalid newsletter input: "name" is required'
|
|
50
|
+
);
|
|
51
|
+
expect(createNewsletter).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should accept all optional fields', async () => {
|
|
55
|
+
const fullInput = {
|
|
56
|
+
name: 'Monthly Newsletter',
|
|
57
|
+
description: 'Our monthly updates',
|
|
58
|
+
sender_name: 'John Doe',
|
|
59
|
+
sender_email: 'john@example.com',
|
|
60
|
+
sender_reply_to: 'newsletter',
|
|
61
|
+
subscribe_on_signup: true,
|
|
62
|
+
show_header_icon: true,
|
|
63
|
+
show_header_title: false,
|
|
64
|
+
};
|
|
65
|
+
const expectedNewsletter = { id: '1', ...fullInput };
|
|
66
|
+
createNewsletter.mockResolvedValue(expectedNewsletter);
|
|
67
|
+
|
|
68
|
+
const result = await createNewsletterService(fullInput);
|
|
69
|
+
|
|
70
|
+
expect(result).toEqual(expectedNewsletter);
|
|
71
|
+
expect(createNewsletter).toHaveBeenCalledWith(expect.objectContaining(fullInput));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should validate sender_email is a valid email', async () => {
|
|
75
|
+
const invalidInput = {
|
|
76
|
+
name: 'Newsletter',
|
|
77
|
+
sender_email: 'not-an-email',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
await expect(createNewsletterService(invalidInput)).rejects.toThrow(
|
|
81
|
+
'Invalid newsletter input:'
|
|
82
|
+
);
|
|
83
|
+
expect(createNewsletter).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should accept valid sender_email', async () => {
|
|
87
|
+
const validInput = {
|
|
88
|
+
name: 'Newsletter',
|
|
89
|
+
sender_email: 'valid@example.com',
|
|
90
|
+
};
|
|
91
|
+
createNewsletter.mockResolvedValue({ id: '1', name: 'Newsletter' });
|
|
92
|
+
|
|
93
|
+
await createNewsletterService(validInput);
|
|
94
|
+
|
|
95
|
+
expect(createNewsletter).toHaveBeenCalledWith(
|
|
96
|
+
expect.objectContaining({
|
|
97
|
+
sender_email: 'valid@example.com',
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should validate sender_reply_to enum values', async () => {
|
|
103
|
+
const invalidInput = {
|
|
104
|
+
name: 'Newsletter',
|
|
105
|
+
sender_reply_to: 'invalid',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
await expect(createNewsletterService(invalidInput)).rejects.toThrow(
|
|
109
|
+
'Invalid newsletter input:'
|
|
110
|
+
);
|
|
111
|
+
expect(createNewsletter).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should accept valid sender_reply_to values', async () => {
|
|
115
|
+
const validValues = ['newsletter', 'support'];
|
|
116
|
+
createNewsletter.mockResolvedValue({ id: '1', name: 'Newsletter' });
|
|
117
|
+
|
|
118
|
+
for (const value of validValues) {
|
|
119
|
+
const input = {
|
|
120
|
+
name: 'Newsletter',
|
|
121
|
+
sender_reply_to: value,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
await createNewsletterService(input);
|
|
125
|
+
|
|
126
|
+
expect(createNewsletter).toHaveBeenCalledWith(
|
|
127
|
+
expect.objectContaining({ sender_reply_to: value })
|
|
128
|
+
);
|
|
129
|
+
vi.clearAllMocks();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should validate subscribe_on_signup is boolean', async () => {
|
|
134
|
+
const invalidInput = {
|
|
135
|
+
name: 'Newsletter',
|
|
136
|
+
subscribe_on_signup: 'yes',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
await expect(createNewsletterService(invalidInput)).rejects.toThrow(
|
|
140
|
+
'Invalid newsletter input:'
|
|
141
|
+
);
|
|
142
|
+
expect(createNewsletter).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should validate show_header_icon is boolean', async () => {
|
|
146
|
+
const invalidInput = {
|
|
147
|
+
name: 'Newsletter',
|
|
148
|
+
show_header_icon: 'true',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
await expect(createNewsletterService(invalidInput)).rejects.toThrow(
|
|
152
|
+
'Invalid newsletter input:'
|
|
153
|
+
);
|
|
154
|
+
expect(createNewsletter).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should validate show_header_title is boolean', async () => {
|
|
158
|
+
const invalidInput = {
|
|
159
|
+
name: 'Newsletter',
|
|
160
|
+
show_header_title: 1,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
await expect(createNewsletterService(invalidInput)).rejects.toThrow(
|
|
164
|
+
'Invalid newsletter input:'
|
|
165
|
+
);
|
|
166
|
+
expect(createNewsletter).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('createNewsletterService - defaults and transformations', () => {
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
createNewsletter.mockResolvedValue({ id: '1', name: 'Newsletter' });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should pass through all provided fields', async () => {
|
|
176
|
+
const input = {
|
|
177
|
+
name: 'Newsletter',
|
|
178
|
+
description: 'Test description',
|
|
179
|
+
sender_name: 'Sender',
|
|
180
|
+
sender_email: 'sender@example.com',
|
|
181
|
+
sender_reply_to: 'support',
|
|
182
|
+
subscribe_on_signup: false,
|
|
183
|
+
show_header_icon: false,
|
|
184
|
+
show_header_title: true,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
await createNewsletterService(input);
|
|
188
|
+
|
|
189
|
+
expect(createNewsletter).toHaveBeenCalledWith(expect.objectContaining(input));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should handle minimal input', async () => {
|
|
193
|
+
const input = {
|
|
194
|
+
name: 'Simple Newsletter',
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
await createNewsletterService(input);
|
|
198
|
+
|
|
199
|
+
expect(createNewsletter).toHaveBeenCalledWith(
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
name: 'Simple Newsletter',
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('createNewsletterService - error handling', () => {
|
|
208
|
+
it('should propagate errors from ghostServiceImproved', async () => {
|
|
209
|
+
const input = {
|
|
210
|
+
name: 'Newsletter',
|
|
211
|
+
};
|
|
212
|
+
createNewsletter.mockRejectedValue(new Error('Ghost API error'));
|
|
213
|
+
|
|
214
|
+
await expect(createNewsletterService(input)).rejects.toThrow('Ghost API error');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -173,6 +173,27 @@ const validators = {
|
|
|
173
173
|
}
|
|
174
174
|
},
|
|
175
175
|
|
|
176
|
+
validateTagUpdateData(updateData) {
|
|
177
|
+
const errors = [];
|
|
178
|
+
|
|
179
|
+
// Name is optional in updates, but if provided, it cannot be empty
|
|
180
|
+
if (updateData.name !== undefined && updateData.name.trim().length === 0) {
|
|
181
|
+
errors.push({ field: 'name', message: 'Tag name cannot be empty' });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate slug format if provided
|
|
185
|
+
if (updateData.slug && !/^[a-z0-9-]+$/.test(updateData.slug)) {
|
|
186
|
+
errors.push({
|
|
187
|
+
field: 'slug',
|
|
188
|
+
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (errors.length > 0) {
|
|
193
|
+
throw new ValidationError('Tag update validation failed', errors);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
176
197
|
async validateImagePath(imagePath) {
|
|
177
198
|
if (!imagePath || typeof imagePath !== 'string') {
|
|
178
199
|
throw new ValidationError('Image path is required and must be a string');
|
|
@@ -224,6 +245,18 @@ const validators = {
|
|
|
224
245
|
throw new ValidationError('Page validation failed', errors);
|
|
225
246
|
}
|
|
226
247
|
},
|
|
248
|
+
|
|
249
|
+
validateNewsletterData(newsletterData) {
|
|
250
|
+
const errors = [];
|
|
251
|
+
|
|
252
|
+
if (!newsletterData.name || newsletterData.name.trim().length === 0) {
|
|
253
|
+
errors.push({ field: 'name', message: 'Newsletter name is required' });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (errors.length > 0) {
|
|
257
|
+
throw new ValidationError('Newsletter validation failed', errors);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
227
260
|
};
|
|
228
261
|
|
|
229
262
|
/**
|
|
@@ -681,13 +714,13 @@ export async function getTags(name) {
|
|
|
681
714
|
}
|
|
682
715
|
}
|
|
683
716
|
|
|
684
|
-
export async function getTag(tagId) {
|
|
717
|
+
export async function getTag(tagId, options = {}) {
|
|
685
718
|
if (!tagId) {
|
|
686
719
|
throw new ValidationError('Tag ID is required');
|
|
687
720
|
}
|
|
688
721
|
|
|
689
722
|
try {
|
|
690
|
-
return await handleApiRequest('tags', 'read', { id: tagId });
|
|
723
|
+
return await handleApiRequest('tags', 'read', { id: tagId }, options);
|
|
691
724
|
} catch (error) {
|
|
692
725
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
693
726
|
throw new NotFoundError('Tag', tagId);
|
|
@@ -701,7 +734,7 @@ export async function updateTag(tagId, updateData) {
|
|
|
701
734
|
throw new ValidationError('Tag ID is required for update');
|
|
702
735
|
}
|
|
703
736
|
|
|
704
|
-
validators.
|
|
737
|
+
validators.validateTagUpdateData(updateData); // Validate update data
|
|
705
738
|
|
|
706
739
|
try {
|
|
707
740
|
const existingTag = await getTag(tagId);
|
|
@@ -739,6 +772,103 @@ export async function deleteTag(tagId) {
|
|
|
739
772
|
}
|
|
740
773
|
}
|
|
741
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Newsletter CRUD Operations
|
|
777
|
+
*/
|
|
778
|
+
|
|
779
|
+
export async function getNewsletters(options = {}) {
|
|
780
|
+
const defaultOptions = {
|
|
781
|
+
limit: 'all',
|
|
782
|
+
...options,
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
try {
|
|
786
|
+
const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
|
|
787
|
+
return newsletters || [];
|
|
788
|
+
} catch (error) {
|
|
789
|
+
console.error('Failed to get newsletters:', error);
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export async function getNewsletter(newsletterId) {
|
|
795
|
+
if (!newsletterId) {
|
|
796
|
+
throw new ValidationError('Newsletter ID is required');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
return await handleApiRequest('newsletters', 'read', { id: newsletterId });
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
803
|
+
throw new NotFoundError('Newsletter', newsletterId);
|
|
804
|
+
}
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export async function createNewsletter(newsletterData) {
|
|
810
|
+
// Validate input
|
|
811
|
+
validators.validateNewsletterData(newsletterData);
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
return await handleApiRequest('newsletters', 'add', newsletterData);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
817
|
+
throw new ValidationError('Newsletter creation failed', [
|
|
818
|
+
{ field: 'newsletter', message: error.originalError },
|
|
819
|
+
]);
|
|
820
|
+
}
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export async function updateNewsletter(newsletterId, updateData) {
|
|
826
|
+
if (!newsletterId) {
|
|
827
|
+
throw new ValidationError('Newsletter ID is required for update');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
// Get existing newsletter to retrieve updated_at for conflict resolution
|
|
832
|
+
const existingNewsletter = await handleApiRequest('newsletters', 'read', {
|
|
833
|
+
id: newsletterId,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// Merge existing data with updates, preserving updated_at
|
|
837
|
+
const mergedData = {
|
|
838
|
+
...existingNewsletter,
|
|
839
|
+
...updateData,
|
|
840
|
+
updated_at: existingNewsletter.updated_at,
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
return await handleApiRequest('newsletters', 'edit', mergedData, { id: newsletterId });
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
846
|
+
throw new NotFoundError('Newsletter', newsletterId);
|
|
847
|
+
}
|
|
848
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
849
|
+
throw new ValidationError('Newsletter update failed', [
|
|
850
|
+
{ field: 'newsletter', message: error.originalError },
|
|
851
|
+
]);
|
|
852
|
+
}
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export async function deleteNewsletter(newsletterId) {
|
|
858
|
+
if (!newsletterId) {
|
|
859
|
+
throw new ValidationError('Newsletter ID is required for deletion');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
return await handleApiRequest('newsletters', 'delete', newsletterId);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
866
|
+
throw new NotFoundError('Newsletter', newsletterId);
|
|
867
|
+
}
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
742
872
|
/**
|
|
743
873
|
* Health check for Ghost API connection
|
|
744
874
|
*/
|
|
@@ -790,5 +920,10 @@ export default {
|
|
|
790
920
|
getTag,
|
|
791
921
|
updateTag,
|
|
792
922
|
deleteTag,
|
|
923
|
+
getNewsletters,
|
|
924
|
+
getNewsletter,
|
|
925
|
+
createNewsletter,
|
|
926
|
+
updateNewsletter,
|
|
927
|
+
deleteNewsletter,
|
|
793
928
|
checkHealth,
|
|
794
929
|
};
|