@jgardner04/ghost-mcp-server 1.5.0 → 1.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -780,6 +780,198 @@ server.tool(
780
780
  }
781
781
  );
782
782
 
783
+ // =============================================================================
784
+ // NEWSLETTER TOOLS
785
+ // =============================================================================
786
+
787
+ // Get Newsletters Tool
788
+ server.tool(
789
+ 'ghost_get_newsletters',
790
+ 'Retrieves a list of newsletters from Ghost CMS with optional filtering.',
791
+ {
792
+ limit: z
793
+ .number()
794
+ .min(1)
795
+ .max(100)
796
+ .optional()
797
+ .describe('Number of newsletters to retrieve (1-100). Default is all.'),
798
+ filter: z.string().optional().describe('Ghost NQL filter string for advanced filtering.'),
799
+ },
800
+ async (input) => {
801
+ console.error(`Executing tool: ghost_get_newsletters`);
802
+ try {
803
+ await loadServices();
804
+
805
+ const options = {};
806
+ if (input.limit !== undefined) options.limit = input.limit;
807
+ if (input.filter !== undefined) options.filter = input.filter;
808
+
809
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
810
+ const newsletters = await ghostServiceImproved.getNewsletters(options);
811
+ console.error(`Retrieved ${newsletters.length} newsletters from Ghost.`);
812
+
813
+ return {
814
+ content: [{ type: 'text', text: JSON.stringify(newsletters, null, 2) }],
815
+ };
816
+ } catch (error) {
817
+ console.error(`Error in ghost_get_newsletters:`, error);
818
+ return {
819
+ content: [{ type: 'text', text: `Error retrieving newsletters: ${error.message}` }],
820
+ isError: true,
821
+ };
822
+ }
823
+ }
824
+ );
825
+
826
+ // Get Newsletter Tool
827
+ server.tool(
828
+ 'ghost_get_newsletter',
829
+ 'Retrieves a single newsletter from Ghost CMS by ID.',
830
+ {
831
+ id: z.string().describe('The ID of the newsletter to retrieve.'),
832
+ },
833
+ async ({ id }) => {
834
+ console.error(`Executing tool: ghost_get_newsletter for ID: ${id}`);
835
+ try {
836
+ await loadServices();
837
+
838
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
839
+ const newsletter = await ghostServiceImproved.getNewsletter(id);
840
+ console.error(`Retrieved newsletter: ${newsletter.name} (ID: ${newsletter.id})`);
841
+
842
+ return {
843
+ content: [{ type: 'text', text: JSON.stringify(newsletter, null, 2) }],
844
+ };
845
+ } catch (error) {
846
+ console.error(`Error in ghost_get_newsletter:`, error);
847
+ return {
848
+ content: [{ type: 'text', text: `Error retrieving newsletter: ${error.message}` }],
849
+ isError: true,
850
+ };
851
+ }
852
+ }
853
+ );
854
+
855
+ // Create Newsletter Tool
856
+ server.tool(
857
+ 'ghost_create_newsletter',
858
+ 'Creates a new newsletter in Ghost CMS with customizable sender settings and display options.',
859
+ {
860
+ name: z.string().describe('The name of the newsletter.'),
861
+ description: z.string().optional().describe('A description for the newsletter.'),
862
+ sender_name: z.string().optional().describe('The sender name for newsletter emails.'),
863
+ sender_email: z
864
+ .string()
865
+ .email()
866
+ .optional()
867
+ .describe('The sender email address for newsletter emails.'),
868
+ sender_reply_to: z
869
+ .enum(['newsletter', 'support'])
870
+ .optional()
871
+ .describe('Reply-to address setting. Options: newsletter, support.'),
872
+ subscribe_on_signup: z
873
+ .boolean()
874
+ .optional()
875
+ .describe('Whether new members are automatically subscribed to this newsletter on signup.'),
876
+ show_header_icon: z
877
+ .boolean()
878
+ .optional()
879
+ .describe('Whether to show the site icon in the newsletter header.'),
880
+ show_header_title: z
881
+ .boolean()
882
+ .optional()
883
+ .describe('Whether to show the site title in the newsletter header.'),
884
+ },
885
+ async (input) => {
886
+ console.error(`Executing tool: ghost_create_newsletter with name: ${input.name}`);
887
+ try {
888
+ await loadServices();
889
+
890
+ const newsletterService = await import('./services/newsletterService.js');
891
+ const createdNewsletter = await newsletterService.createNewsletterService(input);
892
+ console.error(`Newsletter created successfully. Newsletter ID: ${createdNewsletter.id}`);
893
+
894
+ return {
895
+ content: [{ type: 'text', text: JSON.stringify(createdNewsletter, null, 2) }],
896
+ };
897
+ } catch (error) {
898
+ console.error(`Error in ghost_create_newsletter:`, error);
899
+ return {
900
+ content: [{ type: 'text', text: `Error creating newsletter: ${error.message}` }],
901
+ isError: true,
902
+ };
903
+ }
904
+ }
905
+ );
906
+
907
+ // Update Newsletter Tool
908
+ server.tool(
909
+ 'ghost_update_newsletter',
910
+ 'Updates an existing newsletter in Ghost CMS. Can update name, description, sender settings, and display options.',
911
+ {
912
+ id: z.string().describe('The ID of the newsletter to update.'),
913
+ name: z.string().optional().describe('New name for the newsletter.'),
914
+ description: z.string().optional().describe('New description for the newsletter.'),
915
+ sender_name: z.string().optional().describe('New sender name for newsletter emails.'),
916
+ sender_email: z.string().email().optional().describe('New sender email address.'),
917
+ subscribe_on_signup: z
918
+ .boolean()
919
+ .optional()
920
+ .describe('Whether new members are automatically subscribed to this newsletter on signup.'),
921
+ },
922
+ async (input) => {
923
+ console.error(`Executing tool: ghost_update_newsletter for newsletter ID: ${input.id}`);
924
+ try {
925
+ await loadServices();
926
+
927
+ const { id, ...updateData } = input;
928
+
929
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
930
+ const updatedNewsletter = await ghostServiceImproved.updateNewsletter(id, updateData);
931
+ console.error(`Newsletter updated successfully. Newsletter ID: ${updatedNewsletter.id}`);
932
+
933
+ return {
934
+ content: [{ type: 'text', text: JSON.stringify(updatedNewsletter, null, 2) }],
935
+ };
936
+ } catch (error) {
937
+ console.error(`Error in ghost_update_newsletter:`, error);
938
+ return {
939
+ content: [{ type: 'text', text: `Error updating newsletter: ${error.message}` }],
940
+ isError: true,
941
+ };
942
+ }
943
+ }
944
+ );
945
+
946
+ // Delete Newsletter Tool
947
+ server.tool(
948
+ 'ghost_delete_newsletter',
949
+ 'Deletes a newsletter from Ghost CMS by ID. This operation is permanent and cannot be undone.',
950
+ {
951
+ id: z.string().describe('The ID of the newsletter to delete.'),
952
+ },
953
+ async ({ id }) => {
954
+ console.error(`Executing tool: ghost_delete_newsletter for newsletter ID: ${id}`);
955
+ try {
956
+ await loadServices();
957
+
958
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
959
+ await ghostServiceImproved.deleteNewsletter(id);
960
+ console.error(`Newsletter deleted successfully. Newsletter ID: ${id}`);
961
+
962
+ return {
963
+ content: [{ type: 'text', text: `Newsletter ${id} has been successfully deleted.` }],
964
+ };
965
+ } catch (error) {
966
+ console.error(`Error in ghost_delete_newsletter:`, error);
967
+ return {
968
+ content: [{ type: 'text', text: `Error deleting newsletter: ${error.message}` }],
969
+ isError: true,
970
+ };
971
+ }
972
+ }
973
+ );
974
+
783
975
  // --- Main Entry Point ---
784
976
 
785
977
  async function main() {
@@ -792,7 +984,8 @@ async function main() {
792
984
  console.error(
793
985
  'Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ' +
794
986
  'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
795
- 'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages'
987
+ 'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
988
+ 'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
796
989
  );
797
990
  }
798
991
 
@@ -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
+ });
@@ -224,6 +224,18 @@ const validators = {
224
224
  throw new ValidationError('Page validation failed', errors);
225
225
  }
226
226
  },
227
+
228
+ validateNewsletterData(newsletterData) {
229
+ const errors = [];
230
+
231
+ if (!newsletterData.name || newsletterData.name.trim().length === 0) {
232
+ errors.push({ field: 'name', message: 'Newsletter name is required' });
233
+ }
234
+
235
+ if (errors.length > 0) {
236
+ throw new ValidationError('Newsletter validation failed', errors);
237
+ }
238
+ },
227
239
  };
228
240
 
229
241
  /**
@@ -739,6 +751,103 @@ export async function deleteTag(tagId) {
739
751
  }
740
752
  }
741
753
 
754
+ /**
755
+ * Newsletter CRUD Operations
756
+ */
757
+
758
+ export async function getNewsletters(options = {}) {
759
+ const defaultOptions = {
760
+ limit: 'all',
761
+ ...options,
762
+ };
763
+
764
+ try {
765
+ const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
766
+ return newsletters || [];
767
+ } catch (error) {
768
+ console.error('Failed to get newsletters:', error);
769
+ throw error;
770
+ }
771
+ }
772
+
773
+ export async function getNewsletter(newsletterId) {
774
+ if (!newsletterId) {
775
+ throw new ValidationError('Newsletter ID is required');
776
+ }
777
+
778
+ try {
779
+ return await handleApiRequest('newsletters', 'read', { id: newsletterId });
780
+ } catch (error) {
781
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
782
+ throw new NotFoundError('Newsletter', newsletterId);
783
+ }
784
+ throw error;
785
+ }
786
+ }
787
+
788
+ export async function createNewsletter(newsletterData) {
789
+ // Validate input
790
+ validators.validateNewsletterData(newsletterData);
791
+
792
+ try {
793
+ return await handleApiRequest('newsletters', 'add', newsletterData);
794
+ } catch (error) {
795
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
796
+ throw new ValidationError('Newsletter creation failed', [
797
+ { field: 'newsletter', message: error.originalError },
798
+ ]);
799
+ }
800
+ throw error;
801
+ }
802
+ }
803
+
804
+ export async function updateNewsletter(newsletterId, updateData) {
805
+ if (!newsletterId) {
806
+ throw new ValidationError('Newsletter ID is required for update');
807
+ }
808
+
809
+ try {
810
+ // Get existing newsletter to retrieve updated_at for conflict resolution
811
+ const existingNewsletter = await handleApiRequest('newsletters', 'read', {
812
+ id: newsletterId,
813
+ });
814
+
815
+ // Merge existing data with updates, preserving updated_at
816
+ const mergedData = {
817
+ ...existingNewsletter,
818
+ ...updateData,
819
+ updated_at: existingNewsletter.updated_at,
820
+ };
821
+
822
+ return await handleApiRequest('newsletters', 'edit', mergedData, { id: newsletterId });
823
+ } catch (error) {
824
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
825
+ throw new NotFoundError('Newsletter', newsletterId);
826
+ }
827
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
828
+ throw new ValidationError('Newsletter update failed', [
829
+ { field: 'newsletter', message: error.originalError },
830
+ ]);
831
+ }
832
+ throw error;
833
+ }
834
+ }
835
+
836
+ export async function deleteNewsletter(newsletterId) {
837
+ if (!newsletterId) {
838
+ throw new ValidationError('Newsletter ID is required for deletion');
839
+ }
840
+
841
+ try {
842
+ return await handleApiRequest('newsletters', 'delete', newsletterId);
843
+ } catch (error) {
844
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
845
+ throw new NotFoundError('Newsletter', newsletterId);
846
+ }
847
+ throw error;
848
+ }
849
+ }
850
+
742
851
  /**
743
852
  * Health check for Ghost API connection
744
853
  */
@@ -790,5 +899,10 @@ export default {
790
899
  getTag,
791
900
  updateTag,
792
901
  deleteTag,
902
+ getNewsletters,
903
+ getNewsletter,
904
+ createNewsletter,
905
+ updateNewsletter,
906
+ deleteNewsletter,
793
907
  checkHealth,
794
908
  };
@@ -0,0 +1,47 @@
1
+ import Joi from 'joi';
2
+ import { createContextLogger } from '../utils/logger.js';
3
+ import { createNewsletter as createGhostNewsletter } from './ghostServiceImproved.js';
4
+
5
+ /**
6
+ * Validation schema for newsletter input
7
+ */
8
+ const newsletterInputSchema = Joi.object({
9
+ name: Joi.string().required(),
10
+ description: Joi.string().optional(),
11
+ sender_name: Joi.string().optional(),
12
+ sender_email: Joi.string().email().optional(),
13
+ sender_reply_to: Joi.string().valid('newsletter', 'support').optional(),
14
+ subscribe_on_signup: Joi.boolean().strict().optional(),
15
+ show_header_icon: Joi.boolean().strict().optional(),
16
+ show_header_title: Joi.boolean().strict().optional(),
17
+ });
18
+
19
+ /**
20
+ * Service layer function to handle the business logic of creating a newsletter.
21
+ * Validates input and creates a newsletter in Ghost CMS.
22
+ * @param {object} newsletterInput - Data received from the controller.
23
+ * @returns {Promise<object>} The created newsletter object from the Ghost API.
24
+ */
25
+ const createNewsletterService = async (newsletterInput) => {
26
+ const logger = createContextLogger('newsletter-service');
27
+
28
+ // Validate input
29
+ const { error, value: validatedInput } = newsletterInputSchema.validate(newsletterInput);
30
+ if (error) {
31
+ logger.error('Newsletter input validation failed', {
32
+ error: error.details[0].message,
33
+ inputKeys: Object.keys(newsletterInput),
34
+ });
35
+ throw new Error(`Invalid newsletter input: ${error.details[0].message}`);
36
+ }
37
+
38
+ logger.info('Creating Ghost newsletter', {
39
+ name: validatedInput.name,
40
+ hasSenderEmail: !!validatedInput.sender_email,
41
+ });
42
+
43
+ const newNewsletter = await createGhostNewsletter(validatedInput);
44
+ return newNewsletter;
45
+ };
46
+
47
+ export { createNewsletterService };