@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.
- package/package.json +5 -13
- package/src/__tests__/helpers/mockGhostApi.js +36 -0
- package/src/__tests__/mcp_server.test.js +204 -117
- package/src/__tests__/mcp_server_pages.test.js +32 -18
- package/src/config/mcp-config.js +1 -1
- package/src/controllers/__tests__/tagController.test.js +12 -8
- package/src/controllers/tagController.js +2 -2
- package/src/errors/__tests__/index.test.js +3 -3
- package/src/errors/index.js +1 -1
- package/src/index.js +1 -1
- package/src/mcp_server.js +35 -31
- package/src/schemas/__tests__/postSchemas.test.js +19 -0
- package/src/schemas/__tests__/tagSchemas.test.js +1 -1
- package/src/schemas/common.js +2 -2
- package/src/schemas/memberSchemas.js +20 -8
- package/src/schemas/newsletterSchemas.js +10 -10
- package/src/schemas/pageSchemas.js +16 -11
- package/src/schemas/postSchemas.js +22 -15
- package/src/schemas/tagSchemas.js +12 -7
- package/src/schemas/tierSchemas.js +17 -8
- package/src/services/__tests__/ghostServiceImproved.members.test.js +31 -62
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +66 -69
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -48
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +69 -55
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +29 -66
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -62
- package/src/services/__tests__/memberService.test.js +0 -28
- package/src/services/__tests__/tierService.test.js +0 -28
- package/src/services/ghostServiceImproved.js +117 -299
- package/src/services/imageProcessingService.js +1 -1
- package/src/services/memberService.js +0 -13
- package/src/services/tierService.js +0 -13
- package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
- 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.
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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().
|
|
70
|
-
email_only: z.boolean().default(false).
|
|
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
|
-
.
|
|
93
|
+
.meta({ description: 'NQL filter string (e.g., "status:published+featured:true")' }),
|
|
90
94
|
include: z
|
|
91
95
|
.string()
|
|
92
96
|
.optional()
|
|
93
|
-
.
|
|
94
|
-
fields: z.string().optional().
|
|
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
|
-
.
|
|
99
|
-
order: z
|
|
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
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
93
|
+
.meta({ description: 'NQL filter string' }),
|
|
92
94
|
include: z
|
|
93
95
|
.string()
|
|
94
96
|
.optional()
|
|
95
|
-
.
|
|
96
|
-
order: z
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
-
.
|
|
95
|
-
include: z
|
|
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
|
-
.
|
|
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
|
|
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
|
-
{
|
|
177
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
6
|
+
// Mock the Ghost Admin API using shared mock factory
|
|
7
|
+
vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
|
|
8
|
+
|
|
9
|
+
// Mock dotenv
|
|
5
10
|
vi.mock('dotenv', () => mockDotenv());
|
|
6
11
|
|
|
7
|
-
//
|
|
8
|
-
vi.mock('
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
default: class {
|
|
19
|
-
constructor() {
|
|
20
|
-
return {
|
|
21
|
-
newsletters: mockNewslettersApi,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
mockNewslettersApi,
|
|
26
|
-
};
|
|
27
|
-
});
|
|
12
|
+
// Mock logger
|
|
13
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
14
|
+
createContextLogger: createMockContextLogger(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock fs for validateImagePath
|
|
18
|
+
vi.mock('fs/promises', () => ({
|
|
19
|
+
default: {
|
|
20
|
+
access: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
28
23
|
|
|
29
24
|
// Import after mocks are set up
|
|
30
25
|
import {
|
|
@@ -33,12 +28,10 @@ import {
|
|
|
33
28
|
createNewsletter,
|
|
34
29
|
updateNewsletter,
|
|
35
30
|
deleteNewsletter,
|
|
31
|
+
api,
|
|
36
32
|
} from '../ghostServiceImproved.js';
|
|
37
33
|
import { ValidationError, NotFoundError } from '../../errors/index.js';
|
|
38
34
|
|
|
39
|
-
// Get the mock API
|
|
40
|
-
const { mockNewslettersApi } = await vi.importMock('@tryghost/admin-api');
|
|
41
|
-
|
|
42
35
|
describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
43
36
|
beforeEach(() => {
|
|
44
37
|
vi.clearAllMocks();
|
|
@@ -50,37 +43,37 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
50
43
|
{ id: '1', name: 'Newsletter 1', slug: 'newsletter-1' },
|
|
51
44
|
{ id: '2', name: 'Newsletter 2', slug: 'newsletter-2' },
|
|
52
45
|
];
|
|
53
|
-
|
|
46
|
+
api.newsletters.browse.mockResolvedValue(mockNewsletters);
|
|
54
47
|
|
|
55
48
|
const result = await getNewsletters();
|
|
56
49
|
|
|
57
50
|
expect(result).toEqual(mockNewsletters);
|
|
58
|
-
expect(
|
|
51
|
+
expect(api.newsletters.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
|
|
59
52
|
});
|
|
60
53
|
|
|
61
54
|
it('should support custom limit', async () => {
|
|
62
55
|
const mockNewsletters = [{ id: '1', name: 'Newsletter 1' }];
|
|
63
|
-
|
|
56
|
+
api.newsletters.browse.mockResolvedValue(mockNewsletters);
|
|
64
57
|
|
|
65
58
|
await getNewsletters({ limit: 5 });
|
|
66
59
|
|
|
67
|
-
expect(
|
|
60
|
+
expect(api.newsletters.browse).toHaveBeenCalledWith({ limit: 5 }, {});
|
|
68
61
|
});
|
|
69
62
|
|
|
70
63
|
it('should support filter option', async () => {
|
|
71
64
|
const mockNewsletters = [{ id: '1', name: 'Active Newsletter' }];
|
|
72
|
-
|
|
65
|
+
api.newsletters.browse.mockResolvedValue(mockNewsletters);
|
|
73
66
|
|
|
74
67
|
await getNewsletters({ filter: 'status:active' });
|
|
75
68
|
|
|
76
|
-
expect(
|
|
69
|
+
expect(api.newsletters.browse).toHaveBeenCalledWith(
|
|
77
70
|
{ limit: 'all', filter: 'status:active' },
|
|
78
71
|
{}
|
|
79
72
|
);
|
|
80
73
|
});
|
|
81
74
|
|
|
82
75
|
it('should handle empty results', async () => {
|
|
83
|
-
|
|
76
|
+
api.newsletters.browse.mockResolvedValue([]);
|
|
84
77
|
|
|
85
78
|
const result = await getNewsletters();
|
|
86
79
|
|
|
@@ -88,7 +81,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
88
81
|
});
|
|
89
82
|
|
|
90
83
|
it('should propagate API errors', async () => {
|
|
91
|
-
|
|
84
|
+
api.newsletters.browse.mockRejectedValue(new Error('API Error'));
|
|
92
85
|
|
|
93
86
|
await expect(getNewsletters()).rejects.toThrow();
|
|
94
87
|
});
|
|
@@ -97,24 +90,24 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
97
90
|
describe('getNewsletter', () => {
|
|
98
91
|
it('should retrieve a newsletter by ID', async () => {
|
|
99
92
|
const mockNewsletter = { id: 'newsletter-123', name: 'My Newsletter' };
|
|
100
|
-
|
|
93
|
+
api.newsletters.read.mockResolvedValue(mockNewsletter);
|
|
101
94
|
|
|
102
95
|
const result = await getNewsletter('newsletter-123');
|
|
103
96
|
|
|
104
97
|
expect(result).toEqual(mockNewsletter);
|
|
105
|
-
expect(
|
|
98
|
+
expect(api.newsletters.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
|
|
106
99
|
});
|
|
107
100
|
|
|
108
101
|
it('should throw ValidationError if ID is missing', async () => {
|
|
109
102
|
await expect(getNewsletter()).rejects.toThrow(ValidationError);
|
|
110
103
|
await expect(getNewsletter()).rejects.toThrow('Newsletter ID is required');
|
|
111
|
-
expect(
|
|
104
|
+
expect(api.newsletters.read).not.toHaveBeenCalled();
|
|
112
105
|
});
|
|
113
106
|
|
|
114
107
|
it('should throw NotFoundError when newsletter does not exist', async () => {
|
|
115
108
|
const ghostError = new Error('Not found');
|
|
116
109
|
ghostError.response = { status: 404 };
|
|
117
|
-
|
|
110
|
+
api.newsletters.read.mockRejectedValue(ghostError);
|
|
118
111
|
|
|
119
112
|
await expect(getNewsletter('nonexistent')).rejects.toThrow(NotFoundError);
|
|
120
113
|
});
|
|
@@ -127,12 +120,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
127
120
|
description: 'Our weekly updates',
|
|
128
121
|
};
|
|
129
122
|
const createdNewsletter = { id: '1', ...newsletterData };
|
|
130
|
-
|
|
123
|
+
api.newsletters.add.mockResolvedValue(createdNewsletter);
|
|
131
124
|
|
|
132
125
|
const result = await createNewsletter(newsletterData);
|
|
133
126
|
|
|
134
127
|
expect(result).toEqual(createdNewsletter);
|
|
135
|
-
expect(
|
|
128
|
+
expect(api.newsletters.add).toHaveBeenCalledWith(newsletterData, {});
|
|
136
129
|
});
|
|
137
130
|
|
|
138
131
|
it('should create newsletter with sender email', async () => {
|
|
@@ -143,12 +136,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
143
136
|
sender_reply_to: 'newsletter',
|
|
144
137
|
};
|
|
145
138
|
const createdNewsletter = { id: '1', ...newsletterData };
|
|
146
|
-
|
|
139
|
+
api.newsletters.add.mockResolvedValue(createdNewsletter);
|
|
147
140
|
|
|
148
141
|
const result = await createNewsletter(newsletterData);
|
|
149
142
|
|
|
150
143
|
expect(result).toEqual(createdNewsletter);
|
|
151
|
-
expect(
|
|
144
|
+
expect(api.newsletters.add).toHaveBeenCalledWith(newsletterData, {});
|
|
152
145
|
});
|
|
153
146
|
|
|
154
147
|
it('should create newsletter with display options', async () => {
|
|
@@ -159,12 +152,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
159
152
|
show_header_title: false,
|
|
160
153
|
};
|
|
161
154
|
const createdNewsletter = { id: '1', ...newsletterData };
|
|
162
|
-
|
|
155
|
+
api.newsletters.add.mockResolvedValue(createdNewsletter);
|
|
163
156
|
|
|
164
157
|
const result = await createNewsletter(newsletterData);
|
|
165
158
|
|
|
166
159
|
expect(result).toEqual(createdNewsletter);
|
|
167
|
-
expect(
|
|
160
|
+
expect(api.newsletters.add).toHaveBeenCalledWith(newsletterData, {});
|
|
168
161
|
});
|
|
169
162
|
|
|
170
163
|
it('should throw ValidationError if name is missing', async () => {
|
|
@@ -172,7 +165,7 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
172
165
|
|
|
173
166
|
await expect(createNewsletter(invalidData)).rejects.toThrow(ValidationError);
|
|
174
167
|
await expect(createNewsletter(invalidData)).rejects.toThrow('Newsletter validation failed');
|
|
175
|
-
expect(
|
|
168
|
+
expect(api.newsletters.add).not.toHaveBeenCalled();
|
|
176
169
|
});
|
|
177
170
|
|
|
178
171
|
it('should throw ValidationError if name is empty', async () => {
|
|
@@ -180,14 +173,14 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
180
173
|
|
|
181
174
|
await expect(createNewsletter(invalidData)).rejects.toThrow(ValidationError);
|
|
182
175
|
await expect(createNewsletter(invalidData)).rejects.toThrow('Newsletter validation failed');
|
|
183
|
-
expect(
|
|
176
|
+
expect(api.newsletters.add).not.toHaveBeenCalled();
|
|
184
177
|
});
|
|
185
178
|
|
|
186
179
|
it('should handle Ghost API validation errors', async () => {
|
|
187
180
|
const newsletterData = { name: 'Newsletter' };
|
|
188
181
|
const ghostError = new Error('Validation failed');
|
|
189
182
|
ghostError.response = { status: 422 };
|
|
190
|
-
|
|
183
|
+
api.newsletters.add.mockRejectedValue(ghostError);
|
|
191
184
|
|
|
192
185
|
await expect(createNewsletter(newsletterData)).rejects.toThrow();
|
|
193
186
|
});
|
|
@@ -205,20 +198,20 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
205
198
|
const updateData = { name: 'New Name' };
|
|
206
199
|
const updatedNewsletter = { ...existingNewsletter, ...updateData };
|
|
207
200
|
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
api.newsletters.read.mockResolvedValue(existingNewsletter);
|
|
202
|
+
api.newsletters.edit.mockResolvedValue(updatedNewsletter);
|
|
210
203
|
|
|
211
204
|
const result = await updateNewsletter('newsletter-123', updateData);
|
|
212
205
|
|
|
213
206
|
expect(result).toEqual(updatedNewsletter);
|
|
214
|
-
expect(
|
|
207
|
+
expect(api.newsletters.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
|
|
215
208
|
// Should send ONLY updateData + updated_at, NOT the full existing newsletter
|
|
216
|
-
expect(
|
|
217
|
-
{ name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
|
|
218
|
-
{
|
|
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 =
|
|
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
|
-
|
|
239
|
-
|
|
231
|
+
api.newsletters.read.mockResolvedValue(existingNewsletter);
|
|
232
|
+
api.newsletters.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
|
|
240
233
|
|
|
241
234
|
await updateNewsletter('newsletter-123', updateData);
|
|
242
235
|
|
|
243
236
|
// Should send ONLY updateData + updated_at
|
|
244
|
-
expect(
|
|
245
|
-
{ ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
|
|
246
|
-
{
|
|
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(
|
|
246
|
+
expect(api.newsletters.read).not.toHaveBeenCalled();
|
|
254
247
|
});
|
|
255
248
|
|
|
256
249
|
it('should throw NotFoundError if newsletter does not exist', async () => {
|
|
257
250
|
const ghostError = new Error('Not found');
|
|
258
251
|
ghostError.response = { status: 404 };
|
|
259
|
-
|
|
252
|
+
api.newsletters.read.mockRejectedValue(ghostError);
|
|
260
253
|
|
|
261
254
|
await expect(updateNewsletter('nonexistent', { name: 'New Name' })).rejects.toThrow(
|
|
262
255
|
NotFoundError
|
|
@@ -271,39 +264,43 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
|
|
|
271
264
|
};
|
|
272
265
|
const updateData = { description: 'Updated description' };
|
|
273
266
|
|
|
274
|
-
|
|
275
|
-
|
|
267
|
+
api.newsletters.read.mockResolvedValue(existingNewsletter);
|
|
268
|
+
api.newsletters.edit.mockResolvedValue({ ...existingNewsletter, ...updateData });
|
|
276
269
|
|
|
277
270
|
await updateNewsletter('newsletter-123', updateData);
|
|
278
271
|
|
|
279
272
|
// Should send ONLY updateData + updated_at
|
|
280
|
-
expect(
|
|
281
|
-
{
|
|
282
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
303
|
+
api.newsletters.delete.mockRejectedValue(ghostError);
|
|
307
304
|
|
|
308
305
|
await expect(deleteNewsletter('nonexistent')).rejects.toThrow(NotFoundError);
|
|
309
306
|
});
|