@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.
- package/package.json +1 -1
- package/src/__tests__/mcp_server.test.js +10 -4
- package/src/__tests__/mcp_server_improved.test.js +192 -149
- package/src/__tests__/mcp_server_pages.test.js +72 -68
- package/src/errors/__tests__/index.test.js +70 -0
- package/src/errors/index.js +10 -0
- package/src/mcp_server.js +9 -19
- package/src/mcp_server_improved.js +815 -424
- package/src/schemas/__tests__/common.test.js +84 -0
- package/src/schemas/common.js +50 -3
- package/src/services/__tests__/ghostServiceImproved.members.test.js +12 -61
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +392 -0
- package/src/services/__tests__/postService.test.js +7 -99
- package/src/services/__tests__/tierService.test.js +372 -0
- package/src/services/ghostServiceImproved.js +140 -21
- package/src/services/postService.js +4 -30
- package/src/services/tierService.js +304 -0
- package/src/utils/__tests__/tempFileManager.test.js +316 -0
- package/src/utils/__tests__/validation.test.js +163 -0
- package/src/utils/tempFileManager.js +113 -0
- package/src/utils/validation.js +28 -0
|
@@ -177,6 +177,90 @@ describe('Common Schemas', () => {
|
|
|
177
177
|
it('should reject empty strings', () => {
|
|
178
178
|
expect(() => htmlContentSchema.parse('')).toThrow();
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
// XSS Prevention Tests
|
|
182
|
+
describe('XSS sanitization', () => {
|
|
183
|
+
it('should strip script tags', () => {
|
|
184
|
+
const result = htmlContentSchema.parse('<p>Safe</p><script>alert("xss")</script>');
|
|
185
|
+
expect(result).not.toContain('<script>');
|
|
186
|
+
expect(result).not.toContain('alert');
|
|
187
|
+
expect(result).toContain('<p>Safe</p>');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should strip onclick and other event handlers', () => {
|
|
191
|
+
const result = htmlContentSchema.parse('<p onclick="alert(1)">Click me</p>');
|
|
192
|
+
expect(result).not.toContain('onclick');
|
|
193
|
+
expect(result).toContain('<p>Click me</p>');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should strip javascript: URLs', () => {
|
|
197
|
+
const result = htmlContentSchema.parse('<a href="javascript:alert(1)">Link</a>');
|
|
198
|
+
expect(result).not.toContain('javascript:');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should strip onerror handlers on images', () => {
|
|
202
|
+
const result = htmlContentSchema.parse('<img src="x" onerror="alert(1)">');
|
|
203
|
+
expect(result).not.toContain('onerror');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should allow safe tags', () => {
|
|
207
|
+
const safeHtml =
|
|
208
|
+
'<h1>Title</h1><p>Paragraph</p><a href="https://example.com">Link</a><ul><li>Item</li></ul>';
|
|
209
|
+
const result = htmlContentSchema.parse(safeHtml);
|
|
210
|
+
expect(result).toContain('<h1>');
|
|
211
|
+
expect(result).toContain('<p>');
|
|
212
|
+
expect(result).toContain('<a ');
|
|
213
|
+
expect(result).toContain('<ul>');
|
|
214
|
+
expect(result).toContain('<li>');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should allow safe attributes on links', () => {
|
|
218
|
+
const result = htmlContentSchema.parse(
|
|
219
|
+
'<a href="https://example.com" title="Example">Link</a>'
|
|
220
|
+
);
|
|
221
|
+
expect(result).toContain('href="https://example.com"');
|
|
222
|
+
expect(result).toContain('title="Example"');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should allow safe attributes on images', () => {
|
|
226
|
+
const result = htmlContentSchema.parse(
|
|
227
|
+
'<img src="https://example.com/img.jpg" alt="Description" title="Title" width="100" height="100">'
|
|
228
|
+
);
|
|
229
|
+
expect(result).toContain('src="https://example.com/img.jpg"');
|
|
230
|
+
expect(result).toContain('alt="Description"');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should strip style attributes by default', () => {
|
|
234
|
+
const result = htmlContentSchema.parse('<p style="color: red">Styled</p>');
|
|
235
|
+
expect(result).not.toContain('style=');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should strip iframe tags', () => {
|
|
239
|
+
const result = htmlContentSchema.parse(
|
|
240
|
+
'<iframe src="https://evil.com"></iframe><p>Safe</p>'
|
|
241
|
+
);
|
|
242
|
+
expect(result).not.toContain('<iframe');
|
|
243
|
+
expect(result).toContain('<p>Safe</p>');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should strip data: URLs on images', () => {
|
|
247
|
+
// data: URLs can be used for XSS in some contexts
|
|
248
|
+
const result = htmlContentSchema.parse(
|
|
249
|
+
'<img src="data:text/html,<script>alert(1)</script>">'
|
|
250
|
+
);
|
|
251
|
+
// The src should either be removed or the tag stripped
|
|
252
|
+
expect(result).not.toContain('<script>');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should preserve text content while stripping dangerous elements', () => {
|
|
256
|
+
const result = htmlContentSchema.parse(
|
|
257
|
+
'<div>Safe text<script>evil()</script> more text</div>'
|
|
258
|
+
);
|
|
259
|
+
expect(result).toContain('Safe text');
|
|
260
|
+
expect(result).toContain('more text');
|
|
261
|
+
expect(result).not.toContain('evil');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
180
264
|
});
|
|
181
265
|
|
|
182
266
|
describe('titleSchema', () => {
|
package/src/schemas/common.js
CHANGED
|
@@ -1,10 +1,54 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import sanitizeHtml from 'sanitize-html';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Common Zod schemas for validation across all Ghost MCP resources.
|
|
5
6
|
* These validators provide consistent validation and security controls.
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* HTML sanitization configuration
|
|
11
|
+
* Prevents XSS attacks by allowing only safe HTML tags and attributes
|
|
12
|
+
*/
|
|
13
|
+
const htmlSanitizeConfig = {
|
|
14
|
+
allowedTags: [
|
|
15
|
+
'h1',
|
|
16
|
+
'h2',
|
|
17
|
+
'h3',
|
|
18
|
+
'h4',
|
|
19
|
+
'h5',
|
|
20
|
+
'h6',
|
|
21
|
+
'blockquote',
|
|
22
|
+
'p',
|
|
23
|
+
'a',
|
|
24
|
+
'ul',
|
|
25
|
+
'ol',
|
|
26
|
+
'nl',
|
|
27
|
+
'li',
|
|
28
|
+
'b',
|
|
29
|
+
'i',
|
|
30
|
+
'strong',
|
|
31
|
+
'em',
|
|
32
|
+
'strike',
|
|
33
|
+
'code',
|
|
34
|
+
'hr',
|
|
35
|
+
'br',
|
|
36
|
+
'div',
|
|
37
|
+
'span',
|
|
38
|
+
'img',
|
|
39
|
+
'pre',
|
|
40
|
+
'figure',
|
|
41
|
+
'figcaption',
|
|
42
|
+
],
|
|
43
|
+
allowedAttributes: {
|
|
44
|
+
a: ['href', 'name', 'target', 'rel', 'title'],
|
|
45
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
46
|
+
'*': ['class', 'id'],
|
|
47
|
+
},
|
|
48
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
49
|
+
allowedSchemesAppliedToAttributes: ['href', 'src'],
|
|
50
|
+
};
|
|
51
|
+
|
|
8
52
|
// ----- Basic Type Validators -----
|
|
9
53
|
|
|
10
54
|
/**
|
|
@@ -106,10 +150,13 @@ export const visibilitySchema = z.enum(['public', 'members', 'paid', 'tiers'], {
|
|
|
106
150
|
|
|
107
151
|
/**
|
|
108
152
|
* HTML content validation schema
|
|
109
|
-
* Validates that content is a non-empty string
|
|
110
|
-
*
|
|
153
|
+
* Validates that content is a non-empty string and sanitizes HTML to prevent XSS
|
|
154
|
+
* Uses transform to sanitize HTML at schema level (defense-in-depth)
|
|
111
155
|
*/
|
|
112
|
-
export const htmlContentSchema = z
|
|
156
|
+
export const htmlContentSchema = z
|
|
157
|
+
.string()
|
|
158
|
+
.min(1, 'HTML content cannot be empty')
|
|
159
|
+
.transform((html) => sanitizeHtml(html, htmlSanitizeConfig));
|
|
113
160
|
|
|
114
161
|
/**
|
|
115
162
|
* Title validation schema
|
|
@@ -128,24 +128,9 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
128
128
|
expect(result).toEqual(mockCreatedMember);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
it('should throw validation error for invalid email', async () => {
|
|
136
|
-
await expect(createMember({ email: 'invalid-email' })).rejects.toThrow(
|
|
137
|
-
'Member validation failed'
|
|
138
|
-
);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('should throw validation error for invalid labels type', async () => {
|
|
142
|
-
await expect(
|
|
143
|
-
createMember({
|
|
144
|
-
email: 'test@example.com',
|
|
145
|
-
labels: 'premium',
|
|
146
|
-
})
|
|
147
|
-
).rejects.toThrow('Member validation failed');
|
|
148
|
-
});
|
|
131
|
+
// NOTE: Input validation tests (missing email, invalid email, invalid labels)
|
|
132
|
+
// have been moved to MCP layer tests. The service layer now relies on
|
|
133
|
+
// Zod schema validation at the MCP tool layer.
|
|
149
134
|
|
|
150
135
|
it('should handle Ghost API errors', async () => {
|
|
151
136
|
const memberData = {
|
|
@@ -225,11 +210,8 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
225
210
|
);
|
|
226
211
|
});
|
|
227
212
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
'Member validation failed'
|
|
231
|
-
);
|
|
232
|
-
});
|
|
213
|
+
// NOTE: Input validation tests (invalid email in update) have been moved to
|
|
214
|
+
// MCP layer tests. The service layer now relies on Zod schema validation.
|
|
233
215
|
|
|
234
216
|
it('should throw not found error if member does not exist', async () => {
|
|
235
217
|
api.members.read.mockRejectedValue({
|
|
@@ -345,14 +327,8 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
345
327
|
);
|
|
346
328
|
});
|
|
347
329
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
await expect(getMembers({ limit: 101 })).rejects.toThrow('Member query validation failed');
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it('should throw validation error for invalid page', async () => {
|
|
354
|
-
await expect(getMembers({ page: 0 })).rejects.toThrow('Member query validation failed');
|
|
355
|
-
});
|
|
330
|
+
// NOTE: Input validation tests (invalid limit, invalid page) have been moved to
|
|
331
|
+
// MCP layer tests. The service layer now relies on Zod schema validation.
|
|
356
332
|
|
|
357
333
|
it('should return empty array when no members found', async () => {
|
|
358
334
|
api.members.browse.mockResolvedValue([]);
|
|
@@ -407,15 +383,8 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
407
383
|
expect(result).toEqual(mockMember);
|
|
408
384
|
});
|
|
409
385
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it('should throw validation error for invalid email format', async () => {
|
|
415
|
-
await expect(getMember({ email: 'invalid-email' })).rejects.toThrow(
|
|
416
|
-
'Member lookup validation failed'
|
|
417
|
-
);
|
|
418
|
-
});
|
|
386
|
+
// NOTE: Input validation tests (missing id/email, invalid email format) have been
|
|
387
|
+
// moved to MCP layer tests. The service layer now relies on Zod schema validation.
|
|
419
388
|
|
|
420
389
|
it('should throw not found error when member not found by ID', async () => {
|
|
421
390
|
api.members.read.mockRejectedValue({
|
|
@@ -493,27 +462,9 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
493
462
|
);
|
|
494
463
|
});
|
|
495
464
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
it('should throw validation error for non-string query', async () => {
|
|
502
|
-
await expect(searchMembers(123)).rejects.toThrow('Search query validation failed');
|
|
503
|
-
await expect(searchMembers(null)).rejects.toThrow('Search query validation failed');
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it('should throw validation error for invalid limit', async () => {
|
|
507
|
-
await expect(searchMembers('test', { limit: 0 })).rejects.toThrow(
|
|
508
|
-
'Search options validation failed'
|
|
509
|
-
);
|
|
510
|
-
await expect(searchMembers('test', { limit: 51 })).rejects.toThrow(
|
|
511
|
-
'Search options validation failed'
|
|
512
|
-
);
|
|
513
|
-
await expect(searchMembers('test', { limit: 100 })).rejects.toThrow(
|
|
514
|
-
'Search options validation failed'
|
|
515
|
-
);
|
|
516
|
-
});
|
|
465
|
+
// NOTE: Input validation tests (empty query, non-string query, invalid limit)
|
|
466
|
+
// have been moved to MCP layer tests. The service layer now relies on
|
|
467
|
+
// Zod schema validation at the MCP tool layer.
|
|
517
468
|
|
|
518
469
|
it('should sanitize query to prevent NQL injection', async () => {
|
|
519
470
|
const mockMembers = [];
|
|
@@ -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
|
+
});
|