@jgardner04/ghost-mcp-server 1.13.2 → 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.
Files changed (34) hide show
  1. package/package.json +5 -13
  2. package/src/__tests__/helpers/mockGhostApi.js +36 -0
  3. package/src/__tests__/mcp_server.test.js +204 -117
  4. package/src/__tests__/mcp_server_pages.test.js +32 -18
  5. package/src/config/mcp-config.js +1 -1
  6. package/src/controllers/__tests__/tagController.test.js +12 -8
  7. package/src/controllers/tagController.js +2 -2
  8. package/src/errors/__tests__/index.test.js +3 -3
  9. package/src/errors/index.js +1 -1
  10. package/src/index.js +1 -1
  11. package/src/mcp_server.js +35 -31
  12. package/src/schemas/__tests__/postSchemas.test.js +19 -0
  13. package/src/schemas/__tests__/tagSchemas.test.js +1 -1
  14. package/src/schemas/common.js +2 -2
  15. package/src/schemas/memberSchemas.js +20 -8
  16. package/src/schemas/newsletterSchemas.js +10 -10
  17. package/src/schemas/pageSchemas.js +16 -11
  18. package/src/schemas/postSchemas.js +22 -15
  19. package/src/schemas/tagSchemas.js +12 -7
  20. package/src/schemas/tierSchemas.js +17 -8
  21. package/src/services/__tests__/ghostServiceImproved.members.test.js +31 -62
  22. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +66 -69
  23. package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -48
  24. package/src/services/__tests__/ghostServiceImproved.posts.test.js +69 -55
  25. package/src/services/__tests__/ghostServiceImproved.tags.test.js +29 -66
  26. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -62
  27. package/src/services/__tests__/memberService.test.js +0 -28
  28. package/src/services/__tests__/tierService.test.js +0 -28
  29. package/src/services/ghostServiceImproved.js +117 -299
  30. package/src/services/imageProcessingService.js +1 -1
  31. package/src/services/memberService.js +0 -13
  32. package/src/services/tierService.js +0 -13
  33. package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
  34. package/src/utils/nqlSanitizer.js +11 -0
@@ -35,7 +35,7 @@ import {
35
35
  */
36
36
  export const createPostSchema = z.object({
37
37
  title: titleSchema,
38
- html: htmlContentSchema.describe('HTML content of the post'),
38
+ html: htmlContentSchema.meta({ description: 'HTML content of the post' }),
39
39
  slug: slugSchema.optional(),
40
40
  status: postStatusSchema.default('draft'),
41
41
  visibility: visibilitySchema.default('public'),
@@ -57,17 +57,21 @@ export const createPostSchema = z.object({
57
57
  .max(500, 'Twitter description cannot exceed 500 characters')
58
58
  .optional(),
59
59
  canonical_url: canonicalUrlSchema,
60
- tags: tagsSchema.describe(
61
- 'Array of tag names or IDs to associate with the post. On update, this fully replaces the existing tags array (not merged).'
62
- ),
63
- authors: authorsSchema.describe(
64
- 'Array of author IDs or emails. On update, this fully replaces the existing authors array (not merged).'
65
- ),
66
- published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
60
+ tags: tagsSchema.meta({
61
+ description:
62
+ 'Array of tag names or IDs to associate with the post. On update, this fully replaces the existing tags array (not merged).',
63
+ }),
64
+ authors: authorsSchema.meta({
65
+ description:
66
+ 'Array of author IDs or emails. On update, this fully replaces the existing authors array (not merged).',
67
+ }),
68
+ published_at: isoDateSchema
69
+ .optional()
70
+ .meta({ description: 'Scheduled publish time (ISO 8601 format)' }),
67
71
  codeinjection_head: z.string().optional(),
68
72
  codeinjection_foot: z.string().optional(),
69
- custom_template: z.string().optional().describe('Custom template filename'),
70
- email_only: z.boolean().default(false).describe('Whether post is email-only'),
73
+ custom_template: z.string().optional().meta({ description: 'Custom template filename' }),
74
+ email_only: z.boolean().default(false).meta({ description: 'Whether post is email-only' }),
71
75
  });
72
76
 
73
77
  /**
@@ -86,17 +90,20 @@ export const postQuerySchema = z.object({
86
90
  .string()
87
91
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
88
92
  .optional()
89
- .describe('NQL filter string (e.g., "status:published+featured:true")'),
93
+ .meta({ description: 'NQL filter string (e.g., "status:published+featured:true")' }),
90
94
  include: z
91
95
  .string()
92
96
  .optional()
93
- .describe('Comma-separated list of relations (e.g., "tags,authors")'),
94
- fields: z.string().optional().describe('Comma-separated list of fields to return'),
97
+ .meta({ description: 'Comma-separated list of relations (e.g., "tags,authors")' }),
98
+ fields: z.string().optional().meta({ description: 'Comma-separated list of fields to return' }),
95
99
  formats: z
96
100
  .string()
97
101
  .optional()
98
- .describe('Comma-separated list of formats (html, plaintext, mobiledoc)'),
99
- order: z.string().optional().describe('Order results (e.g., "published_at DESC", "title ASC")'),
102
+ .meta({ description: 'Comma-separated list of formats (html, plaintext, mobiledoc)' }),
103
+ order: z
104
+ .string()
105
+ .optional()
106
+ .meta({ description: 'Order results (e.g., "published_at DESC", "title ASC")' }),
100
107
  });
101
108
 
102
109
  /**
@@ -68,9 +68,11 @@ export const tagQueryBaseSchema = z.object({
68
68
  'Tag name contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, and apostrophes are allowed'
69
69
  )
70
70
  .optional()
71
- .describe('Filter by exact tag name (legacy parameter, converted to filter internally)'),
72
- slug: z.string().optional().describe('Filter by tag slug'),
73
- visibility: visibilitySchema.optional().describe('Filter by visibility'),
71
+ .meta({
72
+ description: 'Filter by exact tag name (legacy parameter, converted to filter internally)',
73
+ }),
74
+ slug: z.string().optional().meta({ description: 'Filter by tag slug' }),
75
+ visibility: visibilitySchema.optional().meta({ description: 'Filter by visibility' }),
74
76
  limit: z
75
77
  .union([
76
78
  z.number().int().min(1).max(100),
@@ -79,7 +81,7 @@ export const tagQueryBaseSchema = z.object({
79
81
  ])
80
82
  .default(15)
81
83
  .optional()
82
- .describe('Number of tags to return (1-100) or "all" for all tags'),
84
+ .meta({ description: 'Number of tags to return (1-100) or "all" for all tags' }),
83
85
  page: z
84
86
  .union([z.number().int().min(1), z.string().regex(/^\d+$/).transform(Number)])
85
87
  .default(1)
@@ -88,12 +90,15 @@ export const tagQueryBaseSchema = z.object({
88
90
  .string()
89
91
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
90
92
  .optional()
91
- .describe('NQL filter string'),
93
+ .meta({ description: 'NQL filter string' }),
92
94
  include: z
93
95
  .string()
94
96
  .optional()
95
- .describe('Comma-separated list of relations to include (e.g., "count.posts")'),
96
- order: z.string().optional().describe('Order results (e.g., "name ASC", "created_at DESC")'),
97
+ .meta({ description: 'Comma-separated list of relations to include (e.g., "count.posts")' }),
98
+ order: z
99
+ .string()
100
+ .optional()
101
+ .meta({ description: 'Order results (e.g., "name ASC", "created_at DESC")' }),
97
102
  });
98
103
 
99
104
  /**
@@ -47,16 +47,19 @@ export const createTierSchema = z.object({
47
47
  name: z.string().min(1, 'Name cannot be empty').max(191, 'Name cannot exceed 191 characters'),
48
48
  description: z.string().max(2000, 'Description cannot exceed 2000 characters').optional(),
49
49
  slug: slugSchema.optional(),
50
- active: z.boolean().default(true).describe('Whether tier is currently active/available'),
50
+ active: z
51
+ .boolean()
52
+ .default(true)
53
+ .meta({ description: 'Whether tier is currently active/available' }),
51
54
  type: z
52
55
  .enum(['free', 'paid'], {
53
- errorMap: () => ({ message: 'Type must be free or paid' }),
56
+ error: () => ({ message: 'Type must be free or paid' }),
54
57
  })
55
58
  .default('paid'),
56
59
  welcome_page_url: z.string().url('Invalid welcome page URL').optional(),
57
60
  visibility: z
58
61
  .enum(['public', 'none'], {
59
- errorMap: () => ({ message: 'Visibility must be public or none' }),
62
+ error: () => ({ message: 'Visibility must be public or none' }),
60
63
  })
61
64
  .default('public'),
62
65
  trial_days: z
@@ -64,7 +67,7 @@ export const createTierSchema = z.object({
64
67
  .int()
65
68
  .min(0, 'Trial days must be non-negative')
66
69
  .default(0)
67
- .describe('Number of trial days for paid tiers'),
70
+ .meta({ description: 'Number of trial days for paid tiers' }),
68
71
  currency: z
69
72
  .string()
70
73
  .length(3, 'Currency must be 3-letter ISO code')
@@ -72,7 +75,10 @@ export const createTierSchema = z.object({
72
75
  .optional(),
73
76
  monthly_price: z.number().int().min(0, 'Monthly price must be non-negative').optional(),
74
77
  yearly_price: z.number().int().min(0, 'Yearly price must be non-negative').optional(),
75
- benefits: z.array(z.string()).optional().describe('Array of benefit names/descriptions'),
78
+ benefits: z
79
+ .array(z.string())
80
+ .optional()
81
+ .meta({ description: 'Array of benefit names/descriptions' }),
76
82
  });
77
83
 
78
84
  /**
@@ -91,12 +97,15 @@ export const tierQuerySchema = z.object({
91
97
  .string()
92
98
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
93
99
  .optional()
94
- .describe('NQL filter string (e.g., "type:paid+active:true")'),
95
- include: z.string().optional().describe('Comma-separated list of relations to include'),
100
+ .meta({ description: 'NQL filter string (e.g., "type:paid+active:true")' }),
101
+ include: z
102
+ .string()
103
+ .optional()
104
+ .meta({ description: 'Comma-separated list of relations to include' }),
96
105
  order: z
97
106
  .string()
98
107
  .optional()
99
- .describe('Order results (e.g., "monthly_price ASC", "created_at DESC")'),
108
+ .meta({ description: 'Order results (e.g., "monthly_price ASC", "created_at DESC")' }),
100
109
  });
101
110
 
102
111
  /**
@@ -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 with members support
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(() => {
@@ -173,8 +136,13 @@ describe('ghostServiceImproved - Members', () => {
173
136
  expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId });
174
137
  // Should send ONLY updateData + updated_at, NOT the full existing member
175
138
  expect(api.members.edit).toHaveBeenCalledWith(
176
- { name: 'Jane Doe', note: 'Updated note', updated_at: '2023-01-01T00:00:00.000Z' },
177
- expect.objectContaining({ id: memberId })
139
+ {
140
+ id: memberId,
141
+ name: 'Jane Doe',
142
+ note: 'Updated note',
143
+ updated_at: '2023-01-01T00:00:00.000Z',
144
+ },
145
+ {}
178
146
  );
179
147
  // Verify read-only fields are NOT sent
180
148
  const editCallData = api.members.edit.mock.calls[0][0];
@@ -209,21 +177,20 @@ describe('ghostServiceImproved - Members', () => {
209
177
  });
210
178
 
211
179
  it('should throw validation error for missing member ID', async () => {
212
- await expect(updateMember(null, { name: 'Test' })).rejects.toThrow(
213
- 'Member ID is required for update'
214
- );
180
+ await expect(updateMember(null, { name: 'Test' })).rejects.toThrow('Member ID is required');
215
181
  });
216
182
 
217
183
  // NOTE: Input validation tests (invalid email in update) have been moved to
218
184
  // MCP layer tests. The service layer now relies on Zod schema validation.
219
185
 
220
186
  it('should throw not found error if member does not exist', async () => {
221
- api.members.read.mockRejectedValue({
222
- response: { status: 404 },
223
- message: 'Member not found',
224
- });
187
+ const error404 = new GhostAPIError('members.read', 'Member not found', 404);
188
+ error404.response = { status: 404 };
189
+ api.members.read.mockRejectedValue(error404);
225
190
 
226
- await expect(updateMember('non-existent', { name: 'Test' })).rejects.toThrow();
191
+ const rejection = updateMember('non-existent', { name: 'Test' });
192
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
193
+ await expect(rejection).rejects.toThrow('Member not found');
227
194
  });
228
195
  });
229
196
 
@@ -240,16 +207,17 @@ describe('ghostServiceImproved - Members', () => {
240
207
  });
241
208
 
242
209
  it('should throw validation error for missing member ID', async () => {
243
- await expect(deleteMember(null)).rejects.toThrow('Member ID is required for deletion');
210
+ await expect(deleteMember(null)).rejects.toThrow('Member ID is required');
244
211
  });
245
212
 
246
213
  it('should throw not found error if member does not exist', async () => {
247
- api.members.delete.mockRejectedValue({
248
- response: { status: 404 },
249
- message: 'Member not found',
250
- });
214
+ const error404 = new GhostAPIError('members.delete', 'Member not found', 404);
215
+ error404.response = { status: 404 };
216
+ api.members.delete.mockRejectedValue(error404);
251
217
 
252
- await expect(deleteMember('non-existent')).rejects.toThrow();
218
+ const rejection = deleteMember('non-existent');
219
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
220
+ await expect(rejection).rejects.toThrow('Member not found');
253
221
  });
254
222
  });
255
223
 
@@ -391,12 +359,13 @@ describe('ghostServiceImproved - Members', () => {
391
359
  // moved to MCP layer tests. The service layer now relies on Zod schema validation.
392
360
 
393
361
  it('should throw not found error when member not found by ID', async () => {
394
- api.members.read.mockRejectedValue({
395
- response: { status: 404 },
396
- message: 'Member not found',
397
- });
362
+ const error404 = new GhostAPIError('members.read', 'Member not found', 404);
363
+ error404.response = { status: 404 };
364
+ api.members.read.mockRejectedValue(error404);
398
365
 
399
- await expect(getMember({ id: 'non-existent' })).rejects.toThrow();
366
+ const rejection = getMember({ id: 'non-existent' });
367
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
368
+ await expect(rejection).rejects.toThrow('Member not found');
400
369
  });
401
370
 
402
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 dotenv before other imports
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
- // 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
- });
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
- mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
46
+ api.newsletters.browse.mockResolvedValue(mockNewsletters);
54
47
 
55
48
  const result = await getNewsletters();
56
49
 
57
50
  expect(result).toEqual(mockNewsletters);
58
- expect(mockNewslettersApi.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
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
- mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
56
+ api.newsletters.browse.mockResolvedValue(mockNewsletters);
64
57
 
65
58
  await getNewsletters({ limit: 5 });
66
59
 
67
- expect(mockNewslettersApi.browse).toHaveBeenCalledWith({ limit: 5 }, {});
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
- mockNewslettersApi.browse.mockResolvedValue(mockNewsletters);
65
+ api.newsletters.browse.mockResolvedValue(mockNewsletters);
73
66
 
74
67
  await getNewsletters({ filter: 'status:active' });
75
68
 
76
- expect(mockNewslettersApi.browse).toHaveBeenCalledWith(
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
- mockNewslettersApi.browse.mockResolvedValue([]);
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
- mockNewslettersApi.browse.mockRejectedValue(new Error('API Error'));
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
- mockNewslettersApi.read.mockResolvedValue(mockNewsletter);
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(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
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(mockNewslettersApi.read).not.toHaveBeenCalled();
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
- mockNewslettersApi.read.mockRejectedValue(ghostError);
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
- mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
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(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
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
- mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
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(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
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
- mockNewslettersApi.add.mockResolvedValue(createdNewsletter);
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(mockNewslettersApi.add).toHaveBeenCalledWith(newsletterData, {});
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(mockNewslettersApi.add).not.toHaveBeenCalled();
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(mockNewslettersApi.add).not.toHaveBeenCalled();
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
- mockNewslettersApi.add.mockRejectedValue(ghostError);
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
- mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
209
- mockNewslettersApi.edit.mockResolvedValue(updatedNewsletter);
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(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
207
+ expect(api.newsletters.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
215
208
  // Should send ONLY updateData + updated_at, NOT the full existing newsletter
216
- expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
217
- { name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
218
- { id: 'newsletter-123' }
209
+ expect(api.newsletters.edit).toHaveBeenCalledWith(
210
+ { id: 'newsletter-123', name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
211
+ {}
219
212
  );
220
213
  // Verify read-only fields are NOT sent
221
- const editCallData = mockNewslettersApi.edit.mock.calls[0][0];
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,28 +228,28 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
235
228
  subscribe_on_signup: false,
236
229
  };
237
230
 
238
- mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
239
- mockNewslettersApi.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
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(mockNewslettersApi.edit).toHaveBeenCalledWith(
245
- { ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
246
- { id: 'newsletter-123' }
237
+ expect(api.newsletters.edit).toHaveBeenCalledWith(
238
+ { id: 'newsletter-123', ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
239
+ {}
247
240
  );
248
241
  });
249
242
 
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(mockNewslettersApi.read).not.toHaveBeenCalled();
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
- mockNewslettersApi.read.mockRejectedValue(ghostError);
252
+ api.newsletters.read.mockRejectedValue(ghostError);
260
253
 
261
254
  await expect(updateNewsletter('nonexistent', { name: 'New Name' })).rejects.toThrow(
262
255
  NotFoundError
@@ -271,39 +264,43 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
271
264
  };
272
265
  const updateData = { description: 'Updated description' };
273
266
 
274
- mockNewslettersApi.read.mockResolvedValue(existingNewsletter);
275
- mockNewslettersApi.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
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(mockNewslettersApi.edit).toHaveBeenCalledWith(
281
- { description: 'Updated description', updated_at: '2024-01-01T00:00:00.000Z' },
282
- { id: 'newsletter-123' }
273
+ expect(api.newsletters.edit).toHaveBeenCalledWith(
274
+ {
275
+ id: 'newsletter-123',
276
+ description: 'Updated description',
277
+ updated_at: '2024-01-01T00:00:00.000Z',
278
+ },
279
+ {}
283
280
  );
284
281
  });
285
282
  });
286
283
 
287
284
  describe('deleteNewsletter', () => {
288
285
  it('should delete a newsletter successfully', async () => {
289
- mockNewslettersApi.delete.mockResolvedValue({ id: 'newsletter-123' });
286
+ api.newsletters.delete.mockResolvedValue({ id: 'newsletter-123' });
290
287
 
291
288
  const result = await deleteNewsletter('newsletter-123');
292
289
 
293
290
  expect(result).toEqual({ id: 'newsletter-123' });
294
- expect(mockNewslettersApi.delete).toHaveBeenCalledWith('newsletter-123', {});
291
+ expect(api.newsletters.delete).toHaveBeenCalledWith('newsletter-123', {});
295
292
  });
296
293
 
297
294
  it('should throw ValidationError if ID is missing', async () => {
298
295
  await expect(deleteNewsletter()).rejects.toThrow(ValidationError);
299
296
  await expect(deleteNewsletter()).rejects.toThrow('Newsletter ID is required for deletion');
300
- expect(mockNewslettersApi.delete).not.toHaveBeenCalled();
297
+ expect(api.newsletters.delete).not.toHaveBeenCalled();
301
298
  });
302
299
 
303
300
  it('should throw NotFoundError if newsletter does not exist', async () => {
304
301
  const ghostError = new Error('Not found');
305
302
  ghostError.response = { status: 404 };
306
- mockNewslettersApi.delete.mockRejectedValue(ghostError);
303
+ api.newsletters.delete.mockRejectedValue(ghostError);
307
304
 
308
305
  await expect(deleteNewsletter('nonexistent')).rejects.toThrow(NotFoundError);
309
306
  });