@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,392 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+
5
+ // Mock the Ghost Admin API with tiers 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
+ tiers: {
38
+ add: vi.fn(),
39
+ browse: vi.fn(),
40
+ read: vi.fn(),
41
+ edit: vi.fn(),
42
+ delete: vi.fn(),
43
+ },
44
+ site: {
45
+ read: vi.fn(),
46
+ },
47
+ images: {
48
+ upload: vi.fn(),
49
+ },
50
+ };
51
+ }),
52
+ }));
53
+
54
+ // Mock dotenv
55
+ vi.mock('dotenv', () => mockDotenv());
56
+
57
+ // Mock logger
58
+ vi.mock('../../utils/logger.js', () => ({
59
+ createContextLogger: createMockContextLogger(),
60
+ }));
61
+
62
+ // Mock fs for validateImagePath
63
+ vi.mock('fs/promises', () => ({
64
+ default: {
65
+ access: vi.fn(),
66
+ },
67
+ }));
68
+
69
+ // Import after setting up mocks
70
+ import {
71
+ createTier,
72
+ updateTier,
73
+ deleteTier,
74
+ getTiers,
75
+ getTier,
76
+ api,
77
+ } from '../ghostServiceImproved.js';
78
+
79
+ describe('ghostServiceImproved - Tiers', () => {
80
+ beforeEach(() => {
81
+ // Reset all mocks before each test
82
+ vi.clearAllMocks();
83
+ });
84
+
85
+ describe('createTier', () => {
86
+ it('should create a tier with required fields', async () => {
87
+ const tierData = {
88
+ name: 'Premium',
89
+ currency: 'USD',
90
+ };
91
+
92
+ const mockCreatedTier = {
93
+ id: 'tier-1',
94
+ name: 'Premium',
95
+ currency: 'USD',
96
+ type: 'paid',
97
+ active: true,
98
+ };
99
+
100
+ api.tiers.add.mockResolvedValue(mockCreatedTier);
101
+
102
+ const result = await createTier(tierData);
103
+
104
+ expect(api.tiers.add).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ name: 'Premium',
107
+ currency: 'USD',
108
+ }),
109
+ expect.any(Object)
110
+ );
111
+ expect(result).toEqual(mockCreatedTier);
112
+ });
113
+
114
+ it('should create a tier with all optional fields', async () => {
115
+ const tierData = {
116
+ name: 'Premium Membership',
117
+ currency: 'USD',
118
+ description: 'Access to premium content',
119
+ monthly_price: 999,
120
+ yearly_price: 9999,
121
+ benefits: ['Ad-free experience', 'Exclusive content'],
122
+ welcome_page_url: 'https://example.com/welcome',
123
+ };
124
+
125
+ const mockCreatedTier = {
126
+ id: 'tier-2',
127
+ ...tierData,
128
+ type: 'paid',
129
+ active: true,
130
+ };
131
+
132
+ api.tiers.add.mockResolvedValue(mockCreatedTier);
133
+
134
+ const result = await createTier(tierData);
135
+
136
+ expect(api.tiers.add).toHaveBeenCalledWith(
137
+ expect.objectContaining(tierData),
138
+ expect.any(Object)
139
+ );
140
+ expect(result).toEqual(mockCreatedTier);
141
+ });
142
+
143
+ it('should throw ValidationError when name is missing', async () => {
144
+ await expect(
145
+ createTier({
146
+ currency: 'USD',
147
+ })
148
+ ).rejects.toThrow('Tier validation failed');
149
+ });
150
+
151
+ it('should throw ValidationError when currency is missing', async () => {
152
+ await expect(
153
+ createTier({
154
+ name: 'Premium',
155
+ })
156
+ ).rejects.toThrow('Tier validation failed');
157
+ });
158
+
159
+ it('should throw ValidationError when currency is invalid', async () => {
160
+ await expect(
161
+ createTier({
162
+ name: 'Premium',
163
+ currency: 'us',
164
+ })
165
+ ).rejects.toThrow('Tier validation failed');
166
+ });
167
+ });
168
+
169
+ describe('getTiers', () => {
170
+ it('should get all tiers with default options', async () => {
171
+ const mockTiers = [
172
+ {
173
+ id: 'tier-1',
174
+ name: 'Free',
175
+ type: 'free',
176
+ active: true,
177
+ },
178
+ {
179
+ id: 'tier-2',
180
+ name: 'Premium',
181
+ type: 'paid',
182
+ active: true,
183
+ },
184
+ ];
185
+
186
+ api.tiers.browse.mockResolvedValue(mockTiers);
187
+
188
+ const result = await getTiers();
189
+
190
+ expect(api.tiers.browse).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ limit: 15,
193
+ }),
194
+ expect.any(Object)
195
+ );
196
+ expect(result).toEqual(mockTiers);
197
+ });
198
+
199
+ it('should get tiers with custom limit', async () => {
200
+ const mockTiers = [
201
+ {
202
+ id: 'tier-1',
203
+ name: 'Free',
204
+ type: 'free',
205
+ active: true,
206
+ },
207
+ ];
208
+
209
+ api.tiers.browse.mockResolvedValue(mockTiers);
210
+
211
+ const result = await getTiers({ limit: 5 });
212
+
213
+ expect(api.tiers.browse).toHaveBeenCalledWith(
214
+ expect.objectContaining({
215
+ limit: 5,
216
+ }),
217
+ expect.any(Object)
218
+ );
219
+ expect(result).toEqual(mockTiers);
220
+ });
221
+
222
+ it('should get tiers with filter', async () => {
223
+ const mockTiers = [
224
+ {
225
+ id: 'tier-2',
226
+ name: 'Premium',
227
+ type: 'paid',
228
+ active: true,
229
+ },
230
+ ];
231
+
232
+ api.tiers.browse.mockResolvedValue(mockTiers);
233
+
234
+ const result = await getTiers({ filter: 'type:paid' });
235
+
236
+ expect(api.tiers.browse).toHaveBeenCalledWith(
237
+ expect.objectContaining({
238
+ filter: 'type:paid',
239
+ }),
240
+ expect.any(Object)
241
+ );
242
+ expect(result).toEqual(mockTiers);
243
+ });
244
+
245
+ it('should return empty array when no tiers found', async () => {
246
+ api.tiers.browse.mockResolvedValue([]);
247
+
248
+ const result = await getTiers();
249
+
250
+ expect(result).toEqual([]);
251
+ });
252
+
253
+ it('should throw ValidationError for invalid limit', async () => {
254
+ await expect(getTiers({ limit: 0 })).rejects.toThrow('Tier query validation failed');
255
+ await expect(getTiers({ limit: 101 })).rejects.toThrow('Tier query validation failed');
256
+ });
257
+ });
258
+
259
+ describe('getTier', () => {
260
+ it('should get a single tier by ID', async () => {
261
+ const mockTier = {
262
+ id: 'tier-1',
263
+ name: 'Premium',
264
+ currency: 'USD',
265
+ type: 'paid',
266
+ active: true,
267
+ };
268
+
269
+ api.tiers.read.mockResolvedValue(mockTier);
270
+
271
+ const result = await getTier('tier-1');
272
+
273
+ expect(api.tiers.read).toHaveBeenCalledWith(
274
+ expect.objectContaining({
275
+ id: 'tier-1',
276
+ }),
277
+ expect.objectContaining({
278
+ id: 'tier-1',
279
+ })
280
+ );
281
+ expect(result).toEqual(mockTier);
282
+ });
283
+
284
+ it('should throw ValidationError when ID is missing', async () => {
285
+ await expect(getTier()).rejects.toThrow('Tier ID is required');
286
+ });
287
+
288
+ it('should throw ValidationError when ID is empty string', async () => {
289
+ await expect(getTier('')).rejects.toThrow('Tier ID is required');
290
+ });
291
+
292
+ it('should throw NotFoundError when tier does not exist', async () => {
293
+ const mockError = new Error('Tier not found');
294
+ mockError.response = { status: 404 };
295
+
296
+ api.tiers.read.mockRejectedValue(mockError);
297
+
298
+ await expect(getTier('nonexistent-id')).rejects.toThrow();
299
+ });
300
+ });
301
+
302
+ describe('updateTier', () => {
303
+ it('should update a tier', async () => {
304
+ const existingTier = {
305
+ id: 'tier-1',
306
+ name: 'Premium',
307
+ currency: 'USD',
308
+ monthly_price: 999,
309
+ updated_at: '2024-01-01T00:00:00.000Z',
310
+ };
311
+
312
+ const updateData = {
313
+ name: 'Premium Plus',
314
+ monthly_price: 1299,
315
+ };
316
+
317
+ const mockUpdatedTier = {
318
+ ...existingTier,
319
+ ...updateData,
320
+ };
321
+
322
+ api.tiers.read.mockResolvedValue(existingTier);
323
+ api.tiers.edit.mockResolvedValue(mockUpdatedTier);
324
+
325
+ const result = await updateTier('tier-1', updateData);
326
+
327
+ expect(api.tiers.read).toHaveBeenCalledWith(
328
+ expect.objectContaining({ id: 'tier-1' }),
329
+ expect.objectContaining({ id: 'tier-1' })
330
+ );
331
+ expect(api.tiers.edit).toHaveBeenCalledWith(
332
+ expect.objectContaining({
333
+ ...existingTier,
334
+ ...updateData,
335
+ }),
336
+ expect.objectContaining({
337
+ id: 'tier-1',
338
+ })
339
+ );
340
+ expect(result).toEqual(mockUpdatedTier);
341
+ });
342
+
343
+ it('should throw ValidationError when ID is missing', async () => {
344
+ await expect(updateTier('', { name: 'Updated' })).rejects.toThrow(
345
+ 'Tier ID is required for update'
346
+ );
347
+ });
348
+
349
+ it('should throw ValidationError for invalid update data', async () => {
350
+ await expect(updateTier('tier-1', { monthly_price: -100 })).rejects.toThrow(
351
+ 'Tier validation failed'
352
+ );
353
+ });
354
+
355
+ it('should throw NotFoundError when tier does not exist', async () => {
356
+ const mockError = new Error('Tier not found');
357
+ mockError.response = { status: 404 };
358
+
359
+ api.tiers.read.mockRejectedValue(mockError);
360
+
361
+ await expect(updateTier('nonexistent-id', { name: 'Updated' })).rejects.toThrow();
362
+ });
363
+ });
364
+
365
+ describe('deleteTier', () => {
366
+ it('should delete a tier', async () => {
367
+ api.tiers.delete.mockResolvedValue({ success: true });
368
+
369
+ const result = await deleteTier('tier-1');
370
+
371
+ expect(api.tiers.delete).toHaveBeenCalledWith('tier-1', expect.any(Object));
372
+ expect(result).toEqual({ success: true });
373
+ });
374
+
375
+ it('should throw ValidationError when ID is missing', async () => {
376
+ await expect(deleteTier()).rejects.toThrow('Tier ID is required for deletion');
377
+ });
378
+
379
+ it('should throw ValidationError when ID is empty string', async () => {
380
+ await expect(deleteTier('')).rejects.toThrow('Tier ID is required for deletion');
381
+ });
382
+
383
+ it('should throw NotFoundError when tier does not exist', async () => {
384
+ const mockError = new Error('Tier not found');
385
+ mockError.response = { status: 404 };
386
+
387
+ api.tiers.delete.mockRejectedValue(mockError);
388
+
389
+ await expect(deleteTier('nonexistent-id')).rejects.toThrow();
390
+ });
391
+ });
392
+ });
@@ -1,5 +1,13 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { validateMemberData, validateMemberUpdateData } from '../memberService.js';
2
+ import {
3
+ validateMemberData,
4
+ validateMemberUpdateData,
5
+ validateMemberQueryOptions,
6
+ validateMemberLookup,
7
+ validateSearchQuery,
8
+ validateSearchOptions,
9
+ sanitizeNqlValue,
10
+ } from '../memberService.js';
3
11
 
4
12
  describe('memberService - Validation', () => {
5
13
  describe('validateMemberData', () => {
@@ -242,4 +250,228 @@ describe('memberService - Validation', () => {
242
250
  );
243
251
  });
244
252
  });
253
+
254
+ describe('validateMemberQueryOptions', () => {
255
+ it('should accept empty options', () => {
256
+ expect(() => validateMemberQueryOptions({})).not.toThrow();
257
+ });
258
+
259
+ it('should accept valid limit within bounds', () => {
260
+ expect(() => validateMemberQueryOptions({ limit: 1 })).not.toThrow();
261
+ expect(() => validateMemberQueryOptions({ limit: 50 })).not.toThrow();
262
+ expect(() => validateMemberQueryOptions({ limit: 100 })).not.toThrow();
263
+ });
264
+
265
+ it('should reject limit below minimum', () => {
266
+ expect(() => validateMemberQueryOptions({ limit: 0 })).toThrow(
267
+ 'Member query validation failed'
268
+ );
269
+ expect(() => validateMemberQueryOptions({ limit: -1 })).toThrow(
270
+ 'Member query validation failed'
271
+ );
272
+ });
273
+
274
+ it('should reject limit above maximum', () => {
275
+ expect(() => validateMemberQueryOptions({ limit: 101 })).toThrow(
276
+ 'Member query validation failed'
277
+ );
278
+ });
279
+
280
+ it('should accept valid page number', () => {
281
+ expect(() => validateMemberQueryOptions({ page: 1 })).not.toThrow();
282
+ expect(() => validateMemberQueryOptions({ page: 100 })).not.toThrow();
283
+ });
284
+
285
+ it('should reject page below minimum', () => {
286
+ expect(() => validateMemberQueryOptions({ page: 0 })).toThrow(
287
+ 'Member query validation failed'
288
+ );
289
+ expect(() => validateMemberQueryOptions({ page: -1 })).toThrow(
290
+ 'Member query validation failed'
291
+ );
292
+ });
293
+
294
+ it('should accept valid filter strings', () => {
295
+ expect(() => validateMemberQueryOptions({ filter: 'status:free' })).not.toThrow();
296
+ expect(() => validateMemberQueryOptions({ filter: 'status:paid' })).not.toThrow();
297
+ expect(() => validateMemberQueryOptions({ filter: 'subscribed:true' })).not.toThrow();
298
+ });
299
+
300
+ it('should reject empty filter string', () => {
301
+ expect(() => validateMemberQueryOptions({ filter: '' })).toThrow(
302
+ 'Member query validation failed'
303
+ );
304
+ expect(() => validateMemberQueryOptions({ filter: ' ' })).toThrow(
305
+ 'Member query validation failed'
306
+ );
307
+ });
308
+
309
+ it('should accept valid order strings', () => {
310
+ expect(() => validateMemberQueryOptions({ order: 'created_at desc' })).not.toThrow();
311
+ expect(() => validateMemberQueryOptions({ order: 'email asc' })).not.toThrow();
312
+ });
313
+
314
+ it('should reject empty order string', () => {
315
+ expect(() => validateMemberQueryOptions({ order: '' })).toThrow(
316
+ 'Member query validation failed'
317
+ );
318
+ });
319
+
320
+ it('should accept valid include strings', () => {
321
+ expect(() => validateMemberQueryOptions({ include: 'labels' })).not.toThrow();
322
+ expect(() => validateMemberQueryOptions({ include: 'newsletters' })).not.toThrow();
323
+ expect(() => validateMemberQueryOptions({ include: 'labels,newsletters' })).not.toThrow();
324
+ });
325
+
326
+ it('should reject empty include string', () => {
327
+ expect(() => validateMemberQueryOptions({ include: '' })).toThrow(
328
+ 'Member query validation failed'
329
+ );
330
+ });
331
+
332
+ it('should validate multiple options together', () => {
333
+ expect(() =>
334
+ validateMemberQueryOptions({
335
+ limit: 50,
336
+ page: 2,
337
+ filter: 'status:paid',
338
+ order: 'created_at desc',
339
+ include: 'labels,newsletters',
340
+ })
341
+ ).not.toThrow();
342
+ });
343
+ });
344
+
345
+ describe('validateMemberLookup', () => {
346
+ it('should accept valid id', () => {
347
+ expect(() => validateMemberLookup({ id: '12345' })).not.toThrow();
348
+ });
349
+
350
+ it('should accept valid email', () => {
351
+ expect(() => validateMemberLookup({ email: 'test@example.com' })).not.toThrow();
352
+ });
353
+
354
+ it('should reject when both id and email are missing', () => {
355
+ expect(() => validateMemberLookup({})).toThrow('Member lookup validation failed');
356
+ });
357
+
358
+ it('should reject empty id', () => {
359
+ expect(() => validateMemberLookup({ id: '' })).toThrow('Member lookup validation failed');
360
+ expect(() => validateMemberLookup({ id: ' ' })).toThrow('Member lookup validation failed');
361
+ });
362
+
363
+ it('should reject invalid email format', () => {
364
+ expect(() => validateMemberLookup({ email: 'invalid-email' })).toThrow(
365
+ 'Member lookup validation failed'
366
+ );
367
+ expect(() => validateMemberLookup({ email: 'test@' })).toThrow(
368
+ 'Member lookup validation failed'
369
+ );
370
+ });
371
+
372
+ it('should accept when both id and email provided (id takes precedence)', () => {
373
+ expect(() => validateMemberLookup({ id: '12345', email: 'test@example.com' })).not.toThrow();
374
+ });
375
+
376
+ it('should return normalized params with lookupType', () => {
377
+ const resultId = validateMemberLookup({ id: '12345' });
378
+ expect(resultId).toEqual({ id: '12345', lookupType: 'id' });
379
+
380
+ const resultEmail = validateMemberLookup({ email: 'test@example.com' });
381
+ expect(resultEmail).toEqual({ email: 'test@example.com', lookupType: 'email' });
382
+
383
+ // ID takes precedence when both provided
384
+ const resultBoth = validateMemberLookup({ id: '12345', email: 'test@example.com' });
385
+ expect(resultBoth).toEqual({ id: '12345', lookupType: 'id' });
386
+ });
387
+ });
388
+
389
+ describe('validateSearchQuery', () => {
390
+ it('should accept valid search query', () => {
391
+ expect(() => validateSearchQuery('john')).not.toThrow();
392
+ expect(() => validateSearchQuery('john@example.com')).not.toThrow();
393
+ });
394
+
395
+ it('should reject empty search query', () => {
396
+ expect(() => validateSearchQuery('')).toThrow('Search query validation failed');
397
+ expect(() => validateSearchQuery(' ')).toThrow('Search query validation failed');
398
+ });
399
+
400
+ it('should reject non-string search query', () => {
401
+ expect(() => validateSearchQuery(123)).toThrow('Search query validation failed');
402
+ expect(() => validateSearchQuery(null)).toThrow('Search query validation failed');
403
+ expect(() => validateSearchQuery(undefined)).toThrow('Search query validation failed');
404
+ });
405
+
406
+ it('should return sanitized query', () => {
407
+ const result = validateSearchQuery('john');
408
+ expect(result).toBe('john');
409
+ });
410
+
411
+ it('should trim whitespace from query', () => {
412
+ const result = validateSearchQuery(' john ');
413
+ expect(result).toBe('john');
414
+ });
415
+ });
416
+
417
+ describe('validateSearchOptions', () => {
418
+ it('should accept empty options', () => {
419
+ expect(() => validateSearchOptions({})).not.toThrow();
420
+ });
421
+
422
+ it('should accept valid limit within bounds (1-50)', () => {
423
+ expect(() => validateSearchOptions({ limit: 1 })).not.toThrow();
424
+ expect(() => validateSearchOptions({ limit: 25 })).not.toThrow();
425
+ expect(() => validateSearchOptions({ limit: 50 })).not.toThrow();
426
+ });
427
+
428
+ it('should reject limit below minimum', () => {
429
+ expect(() => validateSearchOptions({ limit: 0 })).toThrow('Search options validation failed');
430
+ expect(() => validateSearchOptions({ limit: -1 })).toThrow(
431
+ 'Search options validation failed'
432
+ );
433
+ });
434
+
435
+ it('should reject limit above maximum (50)', () => {
436
+ expect(() => validateSearchOptions({ limit: 51 })).toThrow(
437
+ 'Search options validation failed'
438
+ );
439
+ expect(() => validateSearchOptions({ limit: 100 })).toThrow(
440
+ 'Search options validation failed'
441
+ );
442
+ });
443
+
444
+ it('should reject non-number limit', () => {
445
+ expect(() => validateSearchOptions({ limit: 'ten' })).toThrow(
446
+ 'Search options validation failed'
447
+ );
448
+ });
449
+ });
450
+
451
+ describe('sanitizeNqlValue', () => {
452
+ it('should escape backslashes', () => {
453
+ expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
454
+ });
455
+
456
+ it('should escape single quotes', () => {
457
+ expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
458
+ });
459
+
460
+ it('should escape double quotes', () => {
461
+ expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
462
+ });
463
+
464
+ it('should handle multiple special characters', () => {
465
+ expect(sanitizeNqlValue('test\'value"with\\chars')).toBe('test\\\'value\\"with\\\\chars');
466
+ });
467
+
468
+ it('should not modify strings without special characters', () => {
469
+ expect(sanitizeNqlValue('normalvalue')).toBe('normalvalue');
470
+ expect(sanitizeNqlValue('test@example.com')).toBe('test@example.com');
471
+ });
472
+
473
+ it('should handle empty string', () => {
474
+ expect(sanitizeNqlValue('')).toBe('');
475
+ });
476
+ });
245
477
  });