@jgardner04/ghost-mcp-server 1.10.0 → 1.12.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.
@@ -26,7 +26,11 @@ describe('postService', () => {
26
26
  vi.clearAllMocks();
27
27
  });
28
28
 
29
- describe('createPostService - validation', () => {
29
+ // NOTE: Input validation tests have been moved to MCP layer tests.
30
+ // The postService no longer performs Joi validation - input is validated
31
+ // by Zod schemas at the MCP tool layer (see mcp_server_improved.js).
32
+
33
+ describe('createPostService - basic functionality', () => {
30
34
  it('should accept valid input and create a post', async () => {
31
35
  const validInput = {
32
36
  title: 'Test Post',
@@ -47,41 +51,6 @@ describe('postService', () => {
47
51
  );
48
52
  });
49
53
 
50
- it('should reject input with missing title', async () => {
51
- const invalidInput = {
52
- html: '<p>Test content</p>',
53
- };
54
-
55
- await expect(createPostService(invalidInput)).rejects.toThrow(
56
- 'Invalid post input: "title" is required'
57
- );
58
- expect(createPost).not.toHaveBeenCalled();
59
- });
60
-
61
- it('should reject input with missing html', async () => {
62
- const invalidInput = {
63
- title: 'Test Post',
64
- };
65
-
66
- await expect(createPostService(invalidInput)).rejects.toThrow(
67
- 'Invalid post input: "html" is required'
68
- );
69
- expect(createPost).not.toHaveBeenCalled();
70
- });
71
-
72
- it('should reject input with invalid status', async () => {
73
- const invalidInput = {
74
- title: 'Test Post',
75
- html: '<p>Content</p>',
76
- status: 'invalid-status',
77
- };
78
-
79
- await expect(createPostService(invalidInput)).rejects.toThrow(
80
- 'Invalid post input: "status" must be one of [draft, published, scheduled]'
81
- );
82
- expect(createPost).not.toHaveBeenCalled();
83
- });
84
-
85
54
  it('should accept valid status values', async () => {
86
55
  const statuses = ['draft', 'published', 'scheduled'];
87
56
  createPost.mockResolvedValue({ id: '1', title: 'Test' });
@@ -100,39 +69,6 @@ describe('postService', () => {
100
69
  }
101
70
  });
102
71
 
103
- it('should validate tags array with maximum length', async () => {
104
- const invalidInput = {
105
- title: 'Test Post',
106
- html: '<p>Content</p>',
107
- tags: Array(11).fill('tag'), // 11 tags exceeds max of 10
108
- };
109
-
110
- await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
111
- expect(createPost).not.toHaveBeenCalled();
112
- });
113
-
114
- it('should validate tag string max length', async () => {
115
- const invalidInput = {
116
- title: 'Test Post',
117
- html: '<p>Content</p>',
118
- tags: ['a'.repeat(51)], // 51 chars exceeds max of 50
119
- };
120
-
121
- await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
122
- expect(createPost).not.toHaveBeenCalled();
123
- });
124
-
125
- it('should validate feature_image is a valid URI', async () => {
126
- const invalidInput = {
127
- title: 'Test Post',
128
- html: '<p>Content</p>',
129
- feature_image: 'not-a-valid-url',
130
- };
131
-
132
- await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
133
- expect(createPost).not.toHaveBeenCalled();
134
- });
135
-
136
72
  it('should accept valid feature_image URI', async () => {
137
73
  const validInput = {
138
74
  title: 'Test Post',
@@ -217,27 +153,7 @@ describe('postService', () => {
217
153
  );
218
154
  });
219
155
 
220
- it('should reject tags array with non-string values', async () => {
221
- const input = {
222
- title: 'Test Post',
223
- html: '<p>Content</p>',
224
- tags: [null, 'valid-tag'],
225
- };
226
-
227
- await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
228
- expect(createPost).not.toHaveBeenCalled();
229
- });
230
-
231
- it('should reject tags array with empty strings', async () => {
232
- const input = {
233
- title: 'Test Post',
234
- html: '<p>Content</p>',
235
- tags: ['', 'valid-tag'],
236
- };
237
-
238
- await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
239
- expect(createPost).not.toHaveBeenCalled();
240
- });
156
+ // NOTE: Tag validation tests (non-string values, empty strings) moved to MCP layer
241
157
 
242
158
  it('should trim whitespace from tag names', async () => {
243
159
  const input = {
@@ -383,15 +299,7 @@ describe('postService', () => {
383
299
  expect(calledDescription).toBe('a'.repeat(497) + '...');
384
300
  });
385
301
 
386
- it('should reject empty HTML content', async () => {
387
- const input = {
388
- title: 'Test Post',
389
- html: '',
390
- };
391
-
392
- await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
393
- expect(createPost).not.toHaveBeenCalled();
394
- });
302
+ // NOTE: Empty HTML validation test moved to MCP layer
395
303
 
396
304
  it('should strip HTML tags and truncate when generating meta_description', async () => {
397
305
  const longHtml = '<p>' + 'word '.repeat(200) + '</p>';
@@ -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
+ });