@jgardner04/ghost-mcp-server 1.10.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.
package/package.json
CHANGED
|
@@ -1330,6 +1330,184 @@ server.tool(
|
|
|
1330
1330
|
}
|
|
1331
1331
|
);
|
|
1332
1332
|
|
|
1333
|
+
// --- Tier Tools ---
|
|
1334
|
+
|
|
1335
|
+
// Get Tiers Tool
|
|
1336
|
+
server.tool(
|
|
1337
|
+
'ghost_get_tiers',
|
|
1338
|
+
'Retrieves a list of tiers (membership levels) from Ghost CMS with optional filtering by type (free/paid).',
|
|
1339
|
+
{
|
|
1340
|
+
limit: z
|
|
1341
|
+
.number()
|
|
1342
|
+
.int()
|
|
1343
|
+
.min(1)
|
|
1344
|
+
.max(100)
|
|
1345
|
+
.optional()
|
|
1346
|
+
.describe('Number of tiers to return (1-100, default 15)'),
|
|
1347
|
+
filter: z.string().optional().describe('NQL filter string (e.g., "type:paid" or "type:free")'),
|
|
1348
|
+
},
|
|
1349
|
+
async (input) => {
|
|
1350
|
+
console.error(`Executing tool: ghost_get_tiers`);
|
|
1351
|
+
try {
|
|
1352
|
+
await loadServices();
|
|
1353
|
+
|
|
1354
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1355
|
+
const tiers = await ghostServiceImproved.getTiers(input);
|
|
1356
|
+
console.error(`Retrieved ${tiers.length} tiers`);
|
|
1357
|
+
|
|
1358
|
+
return {
|
|
1359
|
+
content: [{ type: 'text', text: JSON.stringify(tiers, null, 2) }],
|
|
1360
|
+
};
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
console.error(`Error in ghost_get_tiers:`, error);
|
|
1363
|
+
return {
|
|
1364
|
+
content: [{ type: 'text', text: `Error getting tiers: ${error.message}` }],
|
|
1365
|
+
isError: true,
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
// Get Tier Tool
|
|
1372
|
+
server.tool(
|
|
1373
|
+
'ghost_get_tier',
|
|
1374
|
+
'Retrieves a single tier (membership level) from Ghost CMS by ID.',
|
|
1375
|
+
{
|
|
1376
|
+
id: z.string().describe('The ID of the tier to retrieve.'),
|
|
1377
|
+
},
|
|
1378
|
+
async ({ id }) => {
|
|
1379
|
+
console.error(`Executing tool: ghost_get_tier for tier ID: ${id}`);
|
|
1380
|
+
try {
|
|
1381
|
+
await loadServices();
|
|
1382
|
+
|
|
1383
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1384
|
+
const tier = await ghostServiceImproved.getTier(id);
|
|
1385
|
+
console.error(`Tier retrieved successfully. Tier ID: ${tier.id}`);
|
|
1386
|
+
|
|
1387
|
+
return {
|
|
1388
|
+
content: [{ type: 'text', text: JSON.stringify(tier, null, 2) }],
|
|
1389
|
+
};
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
console.error(`Error in ghost_get_tier:`, error);
|
|
1392
|
+
return {
|
|
1393
|
+
content: [{ type: 'text', text: `Error getting tier: ${error.message}` }],
|
|
1394
|
+
isError: true,
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
);
|
|
1399
|
+
|
|
1400
|
+
// Create Tier Tool
|
|
1401
|
+
server.tool(
|
|
1402
|
+
'ghost_create_tier',
|
|
1403
|
+
'Creates a new tier (membership level) in Ghost CMS with pricing and benefits.',
|
|
1404
|
+
{
|
|
1405
|
+
name: z.string().describe('Tier name (required)'),
|
|
1406
|
+
description: z.string().optional().describe('Tier description'),
|
|
1407
|
+
monthly_price: z
|
|
1408
|
+
.number()
|
|
1409
|
+
.int()
|
|
1410
|
+
.min(0)
|
|
1411
|
+
.optional()
|
|
1412
|
+
.describe('Monthly price in cents (e.g., 500 = $5.00)'),
|
|
1413
|
+
yearly_price: z
|
|
1414
|
+
.number()
|
|
1415
|
+
.int()
|
|
1416
|
+
.min(0)
|
|
1417
|
+
.optional()
|
|
1418
|
+
.describe('Yearly price in cents (e.g., 5000 = $50.00)'),
|
|
1419
|
+
currency: z.string().length(3).optional().describe('Currency code (e.g., "USD", "EUR")'),
|
|
1420
|
+
benefits: z.array(z.string()).optional().describe('Array of benefit descriptions'),
|
|
1421
|
+
welcome_page_url: z.string().url().optional().describe('Welcome page URL for new subscribers'),
|
|
1422
|
+
},
|
|
1423
|
+
async (input) => {
|
|
1424
|
+
console.error(`Executing tool: ghost_create_tier`);
|
|
1425
|
+
try {
|
|
1426
|
+
await loadServices();
|
|
1427
|
+
|
|
1428
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1429
|
+
const tier = await ghostServiceImproved.createTier(input);
|
|
1430
|
+
console.error(`Tier created successfully. Tier ID: ${tier.id}`);
|
|
1431
|
+
|
|
1432
|
+
return {
|
|
1433
|
+
content: [{ type: 'text', text: JSON.stringify(tier, null, 2) }],
|
|
1434
|
+
};
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
console.error(`Error in ghost_create_tier:`, error);
|
|
1437
|
+
return {
|
|
1438
|
+
content: [{ type: 'text', text: `Error creating tier: ${error.message}` }],
|
|
1439
|
+
isError: true,
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
// Update Tier Tool
|
|
1446
|
+
server.tool(
|
|
1447
|
+
'ghost_update_tier',
|
|
1448
|
+
'Updates an existing tier (membership level) in Ghost CMS. Can update pricing, benefits, and other tier properties.',
|
|
1449
|
+
{
|
|
1450
|
+
id: z.string().describe('The ID of the tier to update (required)'),
|
|
1451
|
+
name: z.string().optional().describe('Updated tier name'),
|
|
1452
|
+
description: z.string().optional().describe('Updated description'),
|
|
1453
|
+
monthly_price: z.number().int().min(0).optional().describe('Updated monthly price in cents'),
|
|
1454
|
+
yearly_price: z.number().int().min(0).optional().describe('Updated yearly price in cents'),
|
|
1455
|
+
currency: z.string().length(3).optional().describe('Updated currency code'),
|
|
1456
|
+
benefits: z.array(z.string()).optional().describe('Updated array of benefit descriptions'),
|
|
1457
|
+
},
|
|
1458
|
+
async (input) => {
|
|
1459
|
+
console.error(`Executing tool: ghost_update_tier for tier ID: ${input.id}`);
|
|
1460
|
+
try {
|
|
1461
|
+
await loadServices();
|
|
1462
|
+
|
|
1463
|
+
const { id, ...updateData } = input;
|
|
1464
|
+
|
|
1465
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1466
|
+
const updatedTier = await ghostServiceImproved.updateTier(id, updateData);
|
|
1467
|
+
console.error(`Tier updated successfully. Tier ID: ${updatedTier.id}`);
|
|
1468
|
+
|
|
1469
|
+
return {
|
|
1470
|
+
content: [{ type: 'text', text: JSON.stringify(updatedTier, null, 2) }],
|
|
1471
|
+
};
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
console.error(`Error in ghost_update_tier:`, error);
|
|
1474
|
+
return {
|
|
1475
|
+
content: [{ type: 'text', text: `Error updating tier: ${error.message}` }],
|
|
1476
|
+
isError: true,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
// Delete Tier Tool
|
|
1483
|
+
server.tool(
|
|
1484
|
+
'ghost_delete_tier',
|
|
1485
|
+
'Deletes a tier (membership level) from Ghost CMS by ID. This operation is permanent and cannot be undone.',
|
|
1486
|
+
{
|
|
1487
|
+
id: z.string().describe('The ID of the tier to delete.'),
|
|
1488
|
+
},
|
|
1489
|
+
async ({ id }) => {
|
|
1490
|
+
console.error(`Executing tool: ghost_delete_tier for tier ID: ${id}`);
|
|
1491
|
+
try {
|
|
1492
|
+
await loadServices();
|
|
1493
|
+
|
|
1494
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1495
|
+
await ghostServiceImproved.deleteTier(id);
|
|
1496
|
+
console.error(`Tier deleted successfully. Tier ID: ${id}`);
|
|
1497
|
+
|
|
1498
|
+
return {
|
|
1499
|
+
content: [{ type: 'text', text: `Tier ${id} has been successfully deleted.` }],
|
|
1500
|
+
};
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
console.error(`Error in ghost_delete_tier:`, error);
|
|
1503
|
+
return {
|
|
1504
|
+
content: [{ type: 'text', text: `Error deleting tier: ${error.message}` }],
|
|
1505
|
+
isError: true,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1333
1511
|
// --- Main Entry Point ---
|
|
1334
1512
|
|
|
1335
1513
|
async function main() {
|
|
@@ -1344,7 +1522,8 @@ async function main() {
|
|
|
1344
1522
|
'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
|
|
1345
1523
|
'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
|
|
1346
1524
|
'ghost_create_member, ghost_update_member, ghost_delete_member, ghost_get_members, ghost_get_member, ghost_search_members, ' +
|
|
1347
|
-
'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
|
|
1525
|
+
'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter, ' +
|
|
1526
|
+
'ghost_get_tiers, ghost_get_tier, ghost_create_tier, ghost_update_tier, ghost_delete_tier'
|
|
1348
1527
|
);
|
|
1349
1528
|
}
|
|
1350
1529
|
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1078,6 +1078,131 @@ export async function deleteNewsletter(newsletterId) {
|
|
|
1078
1078
|
}
|
|
1079
1079
|
}
|
|
1080
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
|
+
|
|
1081
1206
|
/**
|
|
1082
1207
|
* Health check for Ghost API connection
|
|
1083
1208
|
*/
|
|
@@ -1140,5 +1265,10 @@ export default {
|
|
|
1140
1265
|
createNewsletter,
|
|
1141
1266
|
updateNewsletter,
|
|
1142
1267
|
deleteNewsletter,
|
|
1268
|
+
createTier,
|
|
1269
|
+
updateTier,
|
|
1270
|
+
deleteTier,
|
|
1271
|
+
getTiers,
|
|
1272
|
+
getTier,
|
|
1143
1273
|
checkHealth,
|
|
1144
1274
|
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { ValidationError } from '../errors/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maximum length constants (following Ghost's database constraints)
|
|
5
|
+
*/
|
|
6
|
+
const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
|
|
7
|
+
const MAX_DESCRIPTION_LENGTH = 2000; // Reasonable limit for descriptions
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Query constraints for tier browsing
|
|
11
|
+
*/
|
|
12
|
+
const MIN_LIMIT = 1;
|
|
13
|
+
const MAX_LIMIT = 100;
|
|
14
|
+
const MIN_PAGE = 1;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Currency code validation regex (3-letter uppercase)
|
|
18
|
+
*/
|
|
19
|
+
const CURRENCY_REGEX = /^[A-Z]{3}$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* URL validation regex (simple HTTP/HTTPS validation)
|
|
23
|
+
*/
|
|
24
|
+
const URL_REGEX = /^https?:\/\/.+/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates tier data for creation
|
|
28
|
+
* @param {Object} tierData - The tier data to validate
|
|
29
|
+
* @throws {ValidationError} If validation fails
|
|
30
|
+
*/
|
|
31
|
+
export function validateTierData(tierData) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
|
|
34
|
+
// Name is required and must be a non-empty string
|
|
35
|
+
if (!tierData.name || typeof tierData.name !== 'string' || tierData.name.trim().length === 0) {
|
|
36
|
+
errors.push({ field: 'name', message: 'Name is required and must be a non-empty string' });
|
|
37
|
+
} else if (tierData.name.length > MAX_NAME_LENGTH) {
|
|
38
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Currency is required and must be a 3-letter uppercase code
|
|
42
|
+
if (!tierData.currency || typeof tierData.currency !== 'string') {
|
|
43
|
+
errors.push({ field: 'currency', message: 'Currency is required' });
|
|
44
|
+
} else if (!CURRENCY_REGEX.test(tierData.currency)) {
|
|
45
|
+
errors.push({
|
|
46
|
+
field: 'currency',
|
|
47
|
+
message: 'Currency must be a 3-letter uppercase code (e.g., USD, EUR)',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Description is optional but must be a string with valid length if provided
|
|
52
|
+
if (tierData.description !== undefined) {
|
|
53
|
+
if (typeof tierData.description !== 'string') {
|
|
54
|
+
errors.push({ field: 'description', message: 'Description must be a string' });
|
|
55
|
+
} else if (tierData.description.length > MAX_DESCRIPTION_LENGTH) {
|
|
56
|
+
errors.push({
|
|
57
|
+
field: 'description',
|
|
58
|
+
message: `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Monthly price is optional but must be a non-negative number if provided
|
|
64
|
+
if (tierData.monthly_price !== undefined) {
|
|
65
|
+
if (typeof tierData.monthly_price !== 'number' || tierData.monthly_price < 0) {
|
|
66
|
+
errors.push({
|
|
67
|
+
field: 'monthly_price',
|
|
68
|
+
message: 'Monthly price must be a non-negative number',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Yearly price is optional but must be a non-negative number if provided
|
|
74
|
+
if (tierData.yearly_price !== undefined) {
|
|
75
|
+
if (typeof tierData.yearly_price !== 'number' || tierData.yearly_price < 0) {
|
|
76
|
+
errors.push({
|
|
77
|
+
field: 'yearly_price',
|
|
78
|
+
message: 'Yearly price must be a non-negative number',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Benefits is optional but must be an array of non-empty strings if provided
|
|
84
|
+
if (tierData.benefits !== undefined) {
|
|
85
|
+
if (!Array.isArray(tierData.benefits)) {
|
|
86
|
+
errors.push({ field: 'benefits', message: 'Benefits must be an array' });
|
|
87
|
+
} else {
|
|
88
|
+
// Validate each benefit is a non-empty string
|
|
89
|
+
const invalidBenefits = tierData.benefits.filter(
|
|
90
|
+
(benefit) => typeof benefit !== 'string' || benefit.trim().length === 0
|
|
91
|
+
);
|
|
92
|
+
if (invalidBenefits.length > 0) {
|
|
93
|
+
errors.push({
|
|
94
|
+
field: 'benefits',
|
|
95
|
+
message: 'Each benefit must be a non-empty string',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Welcome page URL is optional but must be a valid HTTP/HTTPS URL if provided
|
|
102
|
+
if (tierData.welcome_page_url !== undefined) {
|
|
103
|
+
if (
|
|
104
|
+
typeof tierData.welcome_page_url !== 'string' ||
|
|
105
|
+
!URL_REGEX.test(tierData.welcome_page_url)
|
|
106
|
+
) {
|
|
107
|
+
errors.push({
|
|
108
|
+
field: 'welcome_page_url',
|
|
109
|
+
message: 'Welcome page URL must be a valid URL',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (errors.length > 0) {
|
|
115
|
+
throw new ValidationError('Tier validation failed', errors);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validates tier data for update
|
|
121
|
+
* All fields are optional for updates, but if provided they must be valid
|
|
122
|
+
* @param {Object} updateData - The tier update data to validate
|
|
123
|
+
* @throws {ValidationError} If validation fails
|
|
124
|
+
*/
|
|
125
|
+
export function validateTierUpdateData(updateData) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
|
|
128
|
+
// Name is optional for update but must be a non-empty string with valid length if provided
|
|
129
|
+
if (updateData.name !== undefined) {
|
|
130
|
+
if (typeof updateData.name !== 'string' || updateData.name.trim().length === 0) {
|
|
131
|
+
errors.push({ field: 'name', message: 'Name must be a non-empty string' });
|
|
132
|
+
} else if (updateData.name.length > MAX_NAME_LENGTH) {
|
|
133
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Currency is optional for update but must be a 3-letter uppercase code if provided
|
|
138
|
+
if (updateData.currency !== undefined) {
|
|
139
|
+
if (typeof updateData.currency !== 'string' || !CURRENCY_REGEX.test(updateData.currency)) {
|
|
140
|
+
errors.push({
|
|
141
|
+
field: 'currency',
|
|
142
|
+
message: 'Currency must be a 3-letter uppercase code (e.g., USD, EUR)',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Description is optional but must be a string with valid length if provided
|
|
148
|
+
if (updateData.description !== undefined) {
|
|
149
|
+
if (typeof updateData.description !== 'string') {
|
|
150
|
+
errors.push({ field: 'description', message: 'Description must be a string' });
|
|
151
|
+
} else if (updateData.description.length > MAX_DESCRIPTION_LENGTH) {
|
|
152
|
+
errors.push({
|
|
153
|
+
field: 'description',
|
|
154
|
+
message: `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Monthly price is optional but must be a non-negative number if provided
|
|
160
|
+
if (updateData.monthly_price !== undefined) {
|
|
161
|
+
if (typeof updateData.monthly_price !== 'number' || updateData.monthly_price < 0) {
|
|
162
|
+
errors.push({
|
|
163
|
+
field: 'monthly_price',
|
|
164
|
+
message: 'Monthly price must be a non-negative number',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Yearly price is optional but must be a non-negative number if provided
|
|
170
|
+
if (updateData.yearly_price !== undefined) {
|
|
171
|
+
if (typeof updateData.yearly_price !== 'number' || updateData.yearly_price < 0) {
|
|
172
|
+
errors.push({
|
|
173
|
+
field: 'yearly_price',
|
|
174
|
+
message: 'Yearly price must be a non-negative number',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Benefits is optional but must be an array of non-empty strings if provided
|
|
180
|
+
if (updateData.benefits !== undefined) {
|
|
181
|
+
if (!Array.isArray(updateData.benefits)) {
|
|
182
|
+
errors.push({ field: 'benefits', message: 'Benefits must be an array' });
|
|
183
|
+
} else {
|
|
184
|
+
// Validate each benefit is a non-empty string
|
|
185
|
+
const invalidBenefits = updateData.benefits.filter(
|
|
186
|
+
(benefit) => typeof benefit !== 'string' || benefit.trim().length === 0
|
|
187
|
+
);
|
|
188
|
+
if (invalidBenefits.length > 0) {
|
|
189
|
+
errors.push({
|
|
190
|
+
field: 'benefits',
|
|
191
|
+
message: 'Each benefit must be a non-empty string',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Welcome page URL is optional but must be a valid HTTP/HTTPS URL if provided
|
|
198
|
+
if (updateData.welcome_page_url !== undefined) {
|
|
199
|
+
if (
|
|
200
|
+
typeof updateData.welcome_page_url !== 'string' ||
|
|
201
|
+
!URL_REGEX.test(updateData.welcome_page_url)
|
|
202
|
+
) {
|
|
203
|
+
errors.push({
|
|
204
|
+
field: 'welcome_page_url',
|
|
205
|
+
message: 'Welcome page URL must be a valid URL',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (errors.length > 0) {
|
|
211
|
+
throw new ValidationError('Tier validation failed', errors);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Validates query options for tier browsing
|
|
217
|
+
* @param {Object} options - The query options to validate
|
|
218
|
+
* @param {number} [options.limit] - Number of tiers to return (1-100)
|
|
219
|
+
* @param {number} [options.page] - Page number (1+)
|
|
220
|
+
* @param {string} [options.filter] - NQL filter string
|
|
221
|
+
* @param {string} [options.order] - Order string (e.g., 'created_at desc')
|
|
222
|
+
* @param {string} [options.include] - Include string (e.g., 'monthly_price,yearly_price')
|
|
223
|
+
* @throws {ValidationError} If validation fails
|
|
224
|
+
*/
|
|
225
|
+
export function validateTierQueryOptions(options) {
|
|
226
|
+
const errors = [];
|
|
227
|
+
|
|
228
|
+
// Validate limit
|
|
229
|
+
if (options.limit !== undefined) {
|
|
230
|
+
if (
|
|
231
|
+
typeof options.limit !== 'number' ||
|
|
232
|
+
options.limit < MIN_LIMIT ||
|
|
233
|
+
options.limit > MAX_LIMIT
|
|
234
|
+
) {
|
|
235
|
+
errors.push({
|
|
236
|
+
field: 'limit',
|
|
237
|
+
message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_LIMIT}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Validate page
|
|
243
|
+
if (options.page !== undefined) {
|
|
244
|
+
if (typeof options.page !== 'number' || options.page < MIN_PAGE) {
|
|
245
|
+
errors.push({
|
|
246
|
+
field: 'page',
|
|
247
|
+
message: `Page must be a number >= ${MIN_PAGE}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Validate filter (must be non-empty string if provided)
|
|
253
|
+
if (options.filter !== undefined) {
|
|
254
|
+
if (typeof options.filter !== 'string' || options.filter.trim().length === 0) {
|
|
255
|
+
errors.push({
|
|
256
|
+
field: 'filter',
|
|
257
|
+
message: 'Filter must be a non-empty string',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Validate order (must be non-empty string if provided)
|
|
263
|
+
if (options.order !== undefined) {
|
|
264
|
+
if (typeof options.order !== 'string' || options.order.trim().length === 0) {
|
|
265
|
+
errors.push({
|
|
266
|
+
field: 'order',
|
|
267
|
+
message: 'Order must be a non-empty string',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Validate include (must be non-empty string if provided)
|
|
273
|
+
if (options.include !== undefined) {
|
|
274
|
+
if (typeof options.include !== 'string' || options.include.trim().length === 0) {
|
|
275
|
+
errors.push({
|
|
276
|
+
field: 'include',
|
|
277
|
+
message: 'Include must be a non-empty string',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (errors.length > 0) {
|
|
283
|
+
throw new ValidationError('Tier query validation failed', errors);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Sanitizes a value for use in NQL filters to prevent injection
|
|
289
|
+
* Escapes backslashes, single quotes, and double quotes
|
|
290
|
+
* @param {string} value - The value to sanitize
|
|
291
|
+
* @returns {string} The sanitized value
|
|
292
|
+
*/
|
|
293
|
+
export function sanitizeNqlValue(value) {
|
|
294
|
+
if (!value) return value;
|
|
295
|
+
// Escape backslashes first, then quotes
|
|
296
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export default {
|
|
300
|
+
validateTierData,
|
|
301
|
+
validateTierUpdateData,
|
|
302
|
+
validateTierQueryOptions,
|
|
303
|
+
sanitizeNqlValue,
|
|
304
|
+
};
|