@jgardner04/ghost-mcp-server 1.9.0 → 1.11.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.
@@ -0,0 +1,372 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ validateTierData,
4
+ validateTierUpdateData,
5
+ validateTierQueryOptions,
6
+ sanitizeNqlValue,
7
+ } from '../tierService.js';
8
+ import { ValidationError } from '../../errors/index.js';
9
+
10
+ describe('tierService - Validation', () => {
11
+ describe('validateTierData', () => {
12
+ it('should validate required name field', () => {
13
+ expect(() => validateTierData({})).toThrow(ValidationError);
14
+ expect(() => validateTierData({})).toThrow('Tier validation failed');
15
+ });
16
+
17
+ it('should validate required currency field', () => {
18
+ expect(() => validateTierData({ name: 'Premium' })).toThrow(ValidationError);
19
+ expect(() => validateTierData({ name: 'Premium' })).toThrow('Tier validation failed');
20
+ });
21
+
22
+ it('should accept valid tier data with name and currency', () => {
23
+ expect(() =>
24
+ validateTierData({
25
+ name: 'Premium',
26
+ currency: 'USD',
27
+ })
28
+ ).not.toThrow();
29
+ });
30
+
31
+ it('should validate name is a non-empty string', () => {
32
+ expect(() =>
33
+ validateTierData({
34
+ name: '',
35
+ currency: 'USD',
36
+ })
37
+ ).toThrow('Tier validation failed');
38
+ });
39
+
40
+ it('should validate name does not exceed max length', () => {
41
+ const longName = 'a'.repeat(192);
42
+ expect(() =>
43
+ validateTierData({
44
+ name: longName,
45
+ currency: 'USD',
46
+ })
47
+ ).toThrow('Tier validation failed');
48
+ });
49
+
50
+ it('should validate currency is a 3-letter uppercase code', () => {
51
+ expect(() =>
52
+ validateTierData({
53
+ name: 'Premium',
54
+ currency: 'us',
55
+ })
56
+ ).toThrow('Tier validation failed');
57
+
58
+ expect(() =>
59
+ validateTierData({
60
+ name: 'Premium',
61
+ currency: 'USDD',
62
+ })
63
+ ).toThrow('Tier validation failed');
64
+
65
+ expect(() =>
66
+ validateTierData({
67
+ name: 'Premium',
68
+ currency: '123',
69
+ })
70
+ ).toThrow('Tier validation failed');
71
+ });
72
+
73
+ it('should validate description does not exceed max length', () => {
74
+ const longDescription = 'a'.repeat(2001);
75
+ expect(() =>
76
+ validateTierData({
77
+ name: 'Premium',
78
+ currency: 'USD',
79
+ description: longDescription,
80
+ })
81
+ ).toThrow('Tier validation failed');
82
+ });
83
+
84
+ it('should validate monthly_price is a non-negative number', () => {
85
+ expect(() =>
86
+ validateTierData({
87
+ name: 'Premium',
88
+ currency: 'USD',
89
+ monthly_price: -100,
90
+ })
91
+ ).toThrow('Tier validation failed');
92
+
93
+ expect(() =>
94
+ validateTierData({
95
+ name: 'Premium',
96
+ currency: 'USD',
97
+ monthly_price: 'invalid',
98
+ })
99
+ ).toThrow('Tier validation failed');
100
+ });
101
+
102
+ it('should validate yearly_price is a non-negative number', () => {
103
+ expect(() =>
104
+ validateTierData({
105
+ name: 'Premium',
106
+ currency: 'USD',
107
+ yearly_price: -1000,
108
+ })
109
+ ).toThrow('Tier validation failed');
110
+
111
+ expect(() =>
112
+ validateTierData({
113
+ name: 'Premium',
114
+ currency: 'USD',
115
+ yearly_price: 'invalid',
116
+ })
117
+ ).toThrow('Tier validation failed');
118
+ });
119
+
120
+ it('should validate benefits is an array of strings', () => {
121
+ expect(() =>
122
+ validateTierData({
123
+ name: 'Premium',
124
+ currency: 'USD',
125
+ benefits: 'not an array',
126
+ })
127
+ ).toThrow('Tier validation failed');
128
+
129
+ expect(() =>
130
+ validateTierData({
131
+ name: 'Premium',
132
+ currency: 'USD',
133
+ benefits: [123, 456],
134
+ })
135
+ ).toThrow('Tier validation failed');
136
+
137
+ expect(() =>
138
+ validateTierData({
139
+ name: 'Premium',
140
+ currency: 'USD',
141
+ benefits: ['Benefit 1', ''],
142
+ })
143
+ ).toThrow('Tier validation failed');
144
+ });
145
+
146
+ it('should validate welcome_page_url is a valid URL', () => {
147
+ expect(() =>
148
+ validateTierData({
149
+ name: 'Premium',
150
+ currency: 'USD',
151
+ welcome_page_url: 'not-a-url',
152
+ })
153
+ ).toThrow('Tier validation failed');
154
+
155
+ expect(() =>
156
+ validateTierData({
157
+ name: 'Premium',
158
+ currency: 'USD',
159
+ welcome_page_url: 'ftp://example.com',
160
+ })
161
+ ).toThrow('Tier validation failed');
162
+ });
163
+
164
+ it('should accept valid welcome_page_url', () => {
165
+ expect(() =>
166
+ validateTierData({
167
+ name: 'Premium',
168
+ currency: 'USD',
169
+ welcome_page_url: 'https://example.com/welcome',
170
+ })
171
+ ).not.toThrow();
172
+
173
+ expect(() =>
174
+ validateTierData({
175
+ name: 'Premium',
176
+ currency: 'USD',
177
+ welcome_page_url: 'http://example.com/welcome',
178
+ })
179
+ ).not.toThrow();
180
+ });
181
+
182
+ it('should accept complete valid tier data', () => {
183
+ expect(() =>
184
+ validateTierData({
185
+ name: 'Premium Membership',
186
+ description: 'Access to premium content',
187
+ currency: 'USD',
188
+ monthly_price: 999,
189
+ yearly_price: 9999,
190
+ benefits: ['Ad-free experience', 'Exclusive content', 'Priority support'],
191
+ welcome_page_url: 'https://example.com/welcome',
192
+ })
193
+ ).not.toThrow();
194
+ });
195
+ });
196
+
197
+ describe('validateTierUpdateData', () => {
198
+ it('should accept empty update data', () => {
199
+ expect(() => validateTierUpdateData({})).not.toThrow();
200
+ });
201
+
202
+ it('should validate name if provided', () => {
203
+ expect(() =>
204
+ validateTierUpdateData({
205
+ name: '',
206
+ })
207
+ ).toThrow('Tier validation failed');
208
+ });
209
+
210
+ it('should validate currency if provided', () => {
211
+ expect(() =>
212
+ validateTierUpdateData({
213
+ currency: 'us',
214
+ })
215
+ ).toThrow('Tier validation failed');
216
+ });
217
+
218
+ it('should validate description length if provided', () => {
219
+ const longDescription = 'a'.repeat(2001);
220
+ expect(() =>
221
+ validateTierUpdateData({
222
+ description: longDescription,
223
+ })
224
+ ).toThrow('Tier validation failed');
225
+ });
226
+
227
+ it('should validate monthly_price if provided', () => {
228
+ expect(() =>
229
+ validateTierUpdateData({
230
+ monthly_price: -100,
231
+ })
232
+ ).toThrow('Tier validation failed');
233
+ });
234
+
235
+ it('should validate yearly_price if provided', () => {
236
+ expect(() =>
237
+ validateTierUpdateData({
238
+ yearly_price: -1000,
239
+ })
240
+ ).toThrow('Tier validation failed');
241
+ });
242
+
243
+ it('should validate benefits if provided', () => {
244
+ expect(() =>
245
+ validateTierUpdateData({
246
+ benefits: 'not an array',
247
+ })
248
+ ).toThrow('Tier validation failed');
249
+ });
250
+
251
+ it('should validate welcome_page_url if provided', () => {
252
+ expect(() =>
253
+ validateTierUpdateData({
254
+ welcome_page_url: 'not-a-url',
255
+ })
256
+ ).toThrow('Tier validation failed');
257
+ });
258
+
259
+ it('should accept valid update data', () => {
260
+ expect(() =>
261
+ validateTierUpdateData({
262
+ name: 'Updated Premium',
263
+ monthly_price: 1299,
264
+ benefits: ['New benefit'],
265
+ })
266
+ ).not.toThrow();
267
+ });
268
+ });
269
+
270
+ describe('validateTierQueryOptions', () => {
271
+ it('should accept empty options', () => {
272
+ expect(() => validateTierQueryOptions({})).not.toThrow();
273
+ });
274
+
275
+ it('should validate limit is within range', () => {
276
+ expect(() => validateTierQueryOptions({ limit: 0 })).toThrow('Tier query validation failed');
277
+
278
+ expect(() => validateTierQueryOptions({ limit: 101 })).toThrow(
279
+ 'Tier query validation failed'
280
+ );
281
+
282
+ expect(() => validateTierQueryOptions({ limit: 50 })).not.toThrow();
283
+ });
284
+
285
+ it('should validate limit is a number', () => {
286
+ expect(() => validateTierQueryOptions({ limit: 'invalid' })).toThrow(
287
+ 'Tier query validation failed'
288
+ );
289
+ });
290
+
291
+ it('should validate page is >= 1', () => {
292
+ expect(() => validateTierQueryOptions({ page: 0 })).toThrow('Tier query validation failed');
293
+
294
+ expect(() => validateTierQueryOptions({ page: -1 })).toThrow('Tier query validation failed');
295
+
296
+ expect(() => validateTierQueryOptions({ page: 1 })).not.toThrow();
297
+ });
298
+
299
+ it('should validate page is a number', () => {
300
+ expect(() => validateTierQueryOptions({ page: 'invalid' })).toThrow(
301
+ 'Tier query validation failed'
302
+ );
303
+ });
304
+
305
+ it('should validate filter is a non-empty string', () => {
306
+ expect(() => validateTierQueryOptions({ filter: '' })).toThrow(
307
+ 'Tier query validation failed'
308
+ );
309
+
310
+ expect(() => validateTierQueryOptions({ filter: ' ' })).toThrow(
311
+ 'Tier query validation failed'
312
+ );
313
+
314
+ expect(() => validateTierQueryOptions({ filter: 'type:paid' })).not.toThrow();
315
+ });
316
+
317
+ it('should validate order is a non-empty string', () => {
318
+ expect(() => validateTierQueryOptions({ order: '' })).toThrow('Tier query validation failed');
319
+
320
+ expect(() => validateTierQueryOptions({ order: 'created_at desc' })).not.toThrow();
321
+ });
322
+
323
+ it('should validate include is a non-empty string', () => {
324
+ expect(() => validateTierQueryOptions({ include: '' })).toThrow(
325
+ 'Tier query validation failed'
326
+ );
327
+
328
+ expect(() =>
329
+ validateTierQueryOptions({ include: 'monthly_price,yearly_price' })
330
+ ).not.toThrow();
331
+ });
332
+
333
+ it('should accept valid query options', () => {
334
+ expect(() =>
335
+ validateTierQueryOptions({
336
+ limit: 50,
337
+ page: 2,
338
+ filter: 'type:paid',
339
+ order: 'created_at desc',
340
+ include: 'monthly_price,yearly_price',
341
+ })
342
+ ).not.toThrow();
343
+ });
344
+ });
345
+
346
+ describe('sanitizeNqlValue', () => {
347
+ it('should return value if undefined or null', () => {
348
+ expect(sanitizeNqlValue(null)).toBe(null);
349
+ expect(sanitizeNqlValue(undefined)).toBe(undefined);
350
+ });
351
+
352
+ it('should escape backslashes', () => {
353
+ expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
354
+ });
355
+
356
+ it('should escape single quotes', () => {
357
+ expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
358
+ });
359
+
360
+ it('should escape double quotes', () => {
361
+ expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
362
+ });
363
+
364
+ it('should escape multiple special characters', () => {
365
+ expect(sanitizeNqlValue('test\\value"with\'quotes')).toBe('test\\\\value\\"with\\\'quotes');
366
+ });
367
+
368
+ it('should handle strings without special characters', () => {
369
+ expect(sanitizeNqlValue('simple-value')).toBe('simple-value');
370
+ });
371
+ });
372
+ });
@@ -876,6 +876,111 @@ export async function deleteMember(memberId) {
876
876
  }
877
877
  }
878
878
 
879
+ /**
880
+ * List members from Ghost CMS with optional filtering and pagination
881
+ * @param {Object} [options] - Query options
882
+ * @param {number} [options.limit] - Number of members to return (1-100)
883
+ * @param {number} [options.page] - Page number (1+)
884
+ * @param {string} [options.filter] - NQL filter string (e.g., 'status:paid')
885
+ * @param {string} [options.order] - Order string (e.g., 'created_at desc')
886
+ * @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
887
+ * @returns {Promise<Array>} Array of member objects
888
+ * @throws {ValidationError} If validation fails
889
+ * @throws {GhostAPIError} If the API request fails
890
+ */
891
+ export async function getMembers(options = {}) {
892
+ // Import and validate query options
893
+ const { validateMemberQueryOptions } = await import('./memberService.js');
894
+ validateMemberQueryOptions(options);
895
+
896
+ const defaultOptions = {
897
+ limit: 15,
898
+ ...options,
899
+ };
900
+
901
+ try {
902
+ const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
903
+ return members || [];
904
+ } catch (error) {
905
+ console.error('Failed to get members:', error);
906
+ throw error;
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Get a single member from Ghost CMS by ID or email
912
+ * @param {Object} params - Lookup parameters (id OR email required)
913
+ * @param {string} [params.id] - Member ID
914
+ * @param {string} [params.email] - Member email
915
+ * @returns {Promise<Object>} The member object
916
+ * @throws {ValidationError} If validation fails
917
+ * @throws {NotFoundError} If the member is not found
918
+ * @throws {GhostAPIError} If the API request fails
919
+ */
920
+ export async function getMember(params) {
921
+ // Import and validate lookup parameters
922
+ const { validateMemberLookup, sanitizeNqlValue } = await import('./memberService.js');
923
+ const { lookupType, id, email } = validateMemberLookup(params);
924
+
925
+ try {
926
+ if (lookupType === 'id') {
927
+ // Lookup by ID using read endpoint
928
+ return await handleApiRequest('members', 'read', { id }, { id });
929
+ } else {
930
+ // Lookup by email using browse with filter
931
+ const sanitizedEmail = sanitizeNqlValue(email);
932
+ const members = await handleApiRequest(
933
+ 'members',
934
+ 'browse',
935
+ {},
936
+ { filter: `email:'${sanitizedEmail}'`, limit: 1 }
937
+ );
938
+
939
+ if (!members || members.length === 0) {
940
+ throw new NotFoundError('Member', email);
941
+ }
942
+
943
+ return members[0];
944
+ }
945
+ } catch (error) {
946
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
947
+ throw new NotFoundError('Member', id || email);
948
+ }
949
+ throw error;
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Search members by name or email
955
+ * @param {string} query - Search query (searches name and email fields)
956
+ * @param {Object} [options] - Additional options
957
+ * @param {number} [options.limit] - Maximum number of results (default: 15)
958
+ * @returns {Promise<Array>} Array of matching member objects
959
+ * @throws {ValidationError} If validation fails
960
+ * @throws {GhostAPIError} If the API request fails
961
+ */
962
+ export async function searchMembers(query, options = {}) {
963
+ // Import and validate search query and options
964
+ const { validateSearchQuery, validateSearchOptions, sanitizeNqlValue } =
965
+ await import('./memberService.js');
966
+ validateSearchOptions(options);
967
+ const sanitizedQuery = sanitizeNqlValue(validateSearchQuery(query));
968
+
969
+ const limit = options.limit || 15;
970
+
971
+ // Build NQL filter for name or email containing the query
972
+ // Ghost uses ~ for contains/like matching
973
+ const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
974
+
975
+ try {
976
+ const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
977
+ return members || [];
978
+ } catch (error) {
979
+ console.error('Failed to search members:', error);
980
+ throw error;
981
+ }
982
+ }
983
+
879
984
  /**
880
985
  * Newsletter CRUD Operations
881
986
  */
@@ -973,6 +1078,131 @@ export async function deleteNewsletter(newsletterId) {
973
1078
  }
974
1079
  }
975
1080
 
1081
+ /**
1082
+ * Create a new tier (membership level)
1083
+ * @param {Object} tierData - Tier data
1084
+ * @param {Object} [options={}] - Options for the API request
1085
+ * @returns {Promise<Object>} Created tier
1086
+ */
1087
+ export async function createTier(tierData, options = {}) {
1088
+ const { validateTierData } = await import('./tierService.js');
1089
+ validateTierData(tierData);
1090
+
1091
+ try {
1092
+ return await handleApiRequest('tiers', 'add', tierData, options);
1093
+ } catch (error) {
1094
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
1095
+ throw new ValidationError('Tier creation failed due to validation errors', [
1096
+ { field: 'tier', message: error.originalError },
1097
+ ]);
1098
+ }
1099
+ throw error;
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * Update an existing tier
1105
+ * @param {string} id - Tier ID
1106
+ * @param {Object} updateData - Tier update data
1107
+ * @param {Object} [options={}] - Options for the API request
1108
+ * @returns {Promise<Object>} Updated tier
1109
+ */
1110
+ export async function updateTier(id, updateData, options = {}) {
1111
+ if (!id || typeof id !== 'string' || id.trim().length === 0) {
1112
+ throw new ValidationError('Tier ID is required for update');
1113
+ }
1114
+
1115
+ const { validateTierUpdateData } = await import('./tierService.js');
1116
+ validateTierUpdateData(updateData);
1117
+
1118
+ try {
1119
+ // Get existing tier for merge
1120
+ const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
1121
+
1122
+ // Merge updates with existing data
1123
+ const mergedData = {
1124
+ ...existingTier,
1125
+ ...updateData,
1126
+ updated_at: existingTier.updated_at,
1127
+ };
1128
+
1129
+ return await handleApiRequest('tiers', 'edit', mergedData, { id, ...options });
1130
+ } catch (error) {
1131
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1132
+ throw new NotFoundError('Tier', id);
1133
+ }
1134
+ throw error;
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Delete a tier
1140
+ * @param {string} id - Tier ID
1141
+ * @returns {Promise<Object>} Deletion result
1142
+ */
1143
+ export async function deleteTier(id) {
1144
+ if (!id || typeof id !== 'string' || id.trim().length === 0) {
1145
+ throw new ValidationError('Tier ID is required for deletion');
1146
+ }
1147
+
1148
+ try {
1149
+ return await handleApiRequest('tiers', 'delete', { id });
1150
+ } catch (error) {
1151
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1152
+ throw new NotFoundError('Tier', id);
1153
+ }
1154
+ throw error;
1155
+ }
1156
+ }
1157
+
1158
+ /**
1159
+ * Get all tiers with optional filtering
1160
+ * @param {Object} [options={}] - Query options
1161
+ * @param {number} [options.limit] - Number of tiers to return (1-100, default 15)
1162
+ * @param {number} [options.page] - Page number
1163
+ * @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free")
1164
+ * @param {string} [options.order] - Order string
1165
+ * @param {string} [options.include] - Include string
1166
+ * @returns {Promise<Array>} Array of tiers
1167
+ */
1168
+ export async function getTiers(options = {}) {
1169
+ const { validateTierQueryOptions } = await import('./tierService.js');
1170
+ validateTierQueryOptions(options);
1171
+
1172
+ const defaultOptions = {
1173
+ limit: 15,
1174
+ ...options,
1175
+ };
1176
+
1177
+ try {
1178
+ const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
1179
+ return tiers || [];
1180
+ } catch (error) {
1181
+ console.error('Failed to get tiers:', error);
1182
+ throw error;
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Get a single tier by ID
1188
+ * @param {string} id - Tier ID
1189
+ * @returns {Promise<Object>} Tier object
1190
+ */
1191
+ export async function getTier(id) {
1192
+ if (!id || typeof id !== 'string' || id.trim().length === 0) {
1193
+ throw new ValidationError('Tier ID is required and must be a non-empty string');
1194
+ }
1195
+
1196
+ try {
1197
+ return await handleApiRequest('tiers', 'read', { id }, { id });
1198
+ } catch (error) {
1199
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1200
+ throw new NotFoundError('Tier', id);
1201
+ }
1202
+ throw error;
1203
+ }
1204
+ }
1205
+
976
1206
  /**
977
1207
  * Health check for Ghost API connection
978
1208
  */
@@ -1027,10 +1257,18 @@ export default {
1027
1257
  createMember,
1028
1258
  updateMember,
1029
1259
  deleteMember,
1260
+ getMembers,
1261
+ getMember,
1262
+ searchMembers,
1030
1263
  getNewsletters,
1031
1264
  getNewsletter,
1032
1265
  createNewsletter,
1033
1266
  updateNewsletter,
1034
1267
  deleteNewsletter,
1268
+ createTier,
1269
+ updateTier,
1270
+ deleteTier,
1271
+ getTiers,
1272
+ getTier,
1035
1273
  checkHealth,
1036
1274
  };