@jgardner04/ghost-mcp-server 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/mcp_server_improved.js +300 -2
- package/src/services/__tests__/ghostServiceImproved.members.test.js +288 -1
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +392 -0
- package/src/services/__tests__/memberService.test.js +233 -1
- package/src/services/__tests__/tierService.test.js +372 -0
- package/src/services/ghostServiceImproved.js +238 -0
- package/src/services/memberService.js +190 -0
- package/src/services/tierService.js +304 -0
package/package.json
CHANGED
|
@@ -1019,6 +1019,125 @@ server.tool(
|
|
|
1019
1019
|
}
|
|
1020
1020
|
);
|
|
1021
1021
|
|
|
1022
|
+
// Get Members Tool
|
|
1023
|
+
server.tool(
|
|
1024
|
+
'ghost_get_members',
|
|
1025
|
+
'Retrieves a list of members (subscribers) from Ghost CMS with optional filtering, pagination, and includes.',
|
|
1026
|
+
{
|
|
1027
|
+
limit: z
|
|
1028
|
+
.number()
|
|
1029
|
+
.min(1)
|
|
1030
|
+
.max(100)
|
|
1031
|
+
.optional()
|
|
1032
|
+
.describe('Number of members to retrieve (1-100). Default is 15.'),
|
|
1033
|
+
page: z.number().min(1).optional().describe('Page number for pagination (starts at 1).'),
|
|
1034
|
+
filter: z
|
|
1035
|
+
.string()
|
|
1036
|
+
.optional()
|
|
1037
|
+
.describe('Ghost NQL filter string (e.g., "status:free", "status:paid", "subscribed:true").'),
|
|
1038
|
+
order: z.string().optional().describe('Order string (e.g., "created_at desc", "email asc").'),
|
|
1039
|
+
include: z
|
|
1040
|
+
.string()
|
|
1041
|
+
.optional()
|
|
1042
|
+
.describe('Comma-separated list of related data to include (e.g., "labels,newsletters").'),
|
|
1043
|
+
},
|
|
1044
|
+
async (input) => {
|
|
1045
|
+
console.error(`Executing tool: ghost_get_members`);
|
|
1046
|
+
try {
|
|
1047
|
+
await loadServices();
|
|
1048
|
+
|
|
1049
|
+
const options = {};
|
|
1050
|
+
if (input.limit !== undefined) options.limit = input.limit;
|
|
1051
|
+
if (input.page !== undefined) options.page = input.page;
|
|
1052
|
+
if (input.filter !== undefined) options.filter = input.filter;
|
|
1053
|
+
if (input.order !== undefined) options.order = input.order;
|
|
1054
|
+
if (input.include !== undefined) options.include = input.include;
|
|
1055
|
+
|
|
1056
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1057
|
+
const members = await ghostServiceImproved.getMembers(options);
|
|
1058
|
+
console.error(`Retrieved ${members.length} members from Ghost.`);
|
|
1059
|
+
|
|
1060
|
+
return {
|
|
1061
|
+
content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
|
|
1062
|
+
};
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
console.error(`Error in ghost_get_members:`, error);
|
|
1065
|
+
return {
|
|
1066
|
+
content: [{ type: 'text', text: `Error retrieving members: ${error.message}` }],
|
|
1067
|
+
isError: true,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
// Get Member Tool
|
|
1074
|
+
server.tool(
|
|
1075
|
+
'ghost_get_member',
|
|
1076
|
+
'Retrieves a single member from Ghost CMS by ID or email. Provide either id OR email.',
|
|
1077
|
+
{
|
|
1078
|
+
id: z.string().optional().describe('The ID of the member to retrieve.'),
|
|
1079
|
+
email: z.string().email().optional().describe('The email of the member to retrieve.'),
|
|
1080
|
+
},
|
|
1081
|
+
async ({ id, email }) => {
|
|
1082
|
+
console.error(`Executing tool: ghost_get_member for ${id ? `ID: ${id}` : `email: ${email}`}`);
|
|
1083
|
+
try {
|
|
1084
|
+
await loadServices();
|
|
1085
|
+
|
|
1086
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1087
|
+
const member = await ghostServiceImproved.getMember({ id, email });
|
|
1088
|
+
console.error(`Retrieved member: ${member.email} (ID: ${member.id})`);
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
content: [{ type: 'text', text: JSON.stringify(member, null, 2) }],
|
|
1092
|
+
};
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
console.error(`Error in ghost_get_member:`, error);
|
|
1095
|
+
return {
|
|
1096
|
+
content: [{ type: 'text', text: `Error retrieving member: ${error.message}` }],
|
|
1097
|
+
isError: true,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
// Search Members Tool
|
|
1104
|
+
server.tool(
|
|
1105
|
+
'ghost_search_members',
|
|
1106
|
+
'Searches for members by name or email in Ghost CMS.',
|
|
1107
|
+
{
|
|
1108
|
+
query: z.string().min(1).describe('Search query to match against member name or email.'),
|
|
1109
|
+
limit: z
|
|
1110
|
+
.number()
|
|
1111
|
+
.min(1)
|
|
1112
|
+
.max(50)
|
|
1113
|
+
.optional()
|
|
1114
|
+
.describe('Maximum number of results to return (1-50). Default is 15.'),
|
|
1115
|
+
},
|
|
1116
|
+
async ({ query, limit }) => {
|
|
1117
|
+
console.error(`Executing tool: ghost_search_members with query: ${query}`);
|
|
1118
|
+
try {
|
|
1119
|
+
await loadServices();
|
|
1120
|
+
|
|
1121
|
+
const options = {};
|
|
1122
|
+
if (limit !== undefined) options.limit = limit;
|
|
1123
|
+
|
|
1124
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1125
|
+
const members = await ghostServiceImproved.searchMembers(query, options);
|
|
1126
|
+
console.error(`Found ${members.length} members matching "${query}".`);
|
|
1127
|
+
|
|
1128
|
+
return {
|
|
1129
|
+
content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
|
|
1130
|
+
};
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
console.error(`Error in ghost_search_members:`, error);
|
|
1133
|
+
return {
|
|
1134
|
+
content: [{ type: 'text', text: `Error searching members: ${error.message}` }],
|
|
1135
|
+
isError: true,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1022
1141
|
// =============================================================================
|
|
1023
1142
|
// NEWSLETTER TOOLS
|
|
1024
1143
|
// =============================================================================
|
|
@@ -1211,6 +1330,184 @@ server.tool(
|
|
|
1211
1330
|
}
|
|
1212
1331
|
);
|
|
1213
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
|
+
|
|
1214
1511
|
// --- Main Entry Point ---
|
|
1215
1512
|
|
|
1216
1513
|
async function main() {
|
|
@@ -1224,8 +1521,9 @@ async function main() {
|
|
|
1224
1521
|
'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ' +
|
|
1225
1522
|
'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
|
|
1226
1523
|
'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
|
|
1227
|
-
'ghost_create_member, ghost_update_member, ghost_delete_member, ' +
|
|
1228
|
-
'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
|
|
1524
|
+
'ghost_create_member, ghost_update_member, ghost_delete_member, ghost_get_members, ghost_get_member, ghost_search_members, ' +
|
|
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'
|
|
1229
1527
|
);
|
|
1230
1528
|
}
|
|
1231
1529
|
|
|
@@ -60,7 +60,15 @@ vi.mock('fs/promises', () => ({
|
|
|
60
60
|
}));
|
|
61
61
|
|
|
62
62
|
// Import after setting up mocks
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
createMember,
|
|
65
|
+
updateMember,
|
|
66
|
+
deleteMember,
|
|
67
|
+
getMembers,
|
|
68
|
+
getMember,
|
|
69
|
+
searchMembers,
|
|
70
|
+
api,
|
|
71
|
+
} from '../ghostServiceImproved.js';
|
|
64
72
|
|
|
65
73
|
describe('ghostServiceImproved - Members', () => {
|
|
66
74
|
beforeEach(() => {
|
|
@@ -258,4 +266,283 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
258
266
|
await expect(deleteMember('non-existent')).rejects.toThrow();
|
|
259
267
|
});
|
|
260
268
|
});
|
|
269
|
+
|
|
270
|
+
describe('getMembers', () => {
|
|
271
|
+
it('should return all members with default options', async () => {
|
|
272
|
+
const mockMembers = [
|
|
273
|
+
{ id: 'member-1', email: 'test1@example.com', status: 'free' },
|
|
274
|
+
{ id: 'member-2', email: 'test2@example.com', status: 'paid' },
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
278
|
+
|
|
279
|
+
const result = await getMembers();
|
|
280
|
+
|
|
281
|
+
expect(api.members.browse).toHaveBeenCalled();
|
|
282
|
+
expect(result).toEqual(mockMembers);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should accept pagination options', async () => {
|
|
286
|
+
const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }];
|
|
287
|
+
|
|
288
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
289
|
+
|
|
290
|
+
await getMembers({ limit: 50, page: 2 });
|
|
291
|
+
|
|
292
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
293
|
+
expect.objectContaining({
|
|
294
|
+
limit: 50,
|
|
295
|
+
page: 2,
|
|
296
|
+
}),
|
|
297
|
+
expect.any(Object)
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should accept filter options', async () => {
|
|
302
|
+
const mockMembers = [{ id: 'member-1', email: 'test1@example.com', status: 'paid' }];
|
|
303
|
+
|
|
304
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
305
|
+
|
|
306
|
+
await getMembers({ filter: 'status:paid' });
|
|
307
|
+
|
|
308
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
309
|
+
expect.objectContaining({
|
|
310
|
+
filter: 'status:paid',
|
|
311
|
+
}),
|
|
312
|
+
expect.any(Object)
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should accept order options', async () => {
|
|
317
|
+
const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }];
|
|
318
|
+
|
|
319
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
320
|
+
|
|
321
|
+
await getMembers({ order: 'created_at desc' });
|
|
322
|
+
|
|
323
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
324
|
+
expect.objectContaining({
|
|
325
|
+
order: 'created_at desc',
|
|
326
|
+
}),
|
|
327
|
+
expect.any(Object)
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should accept include options', async () => {
|
|
332
|
+
const mockMembers = [
|
|
333
|
+
{ id: 'member-1', email: 'test1@example.com', labels: [], newsletters: [] },
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
337
|
+
|
|
338
|
+
await getMembers({ include: 'labels,newsletters' });
|
|
339
|
+
|
|
340
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
341
|
+
expect.objectContaining({
|
|
342
|
+
include: 'labels,newsletters',
|
|
343
|
+
}),
|
|
344
|
+
expect.any(Object)
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should throw validation error for invalid limit', async () => {
|
|
349
|
+
await expect(getMembers({ limit: 0 })).rejects.toThrow('Member query validation failed');
|
|
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
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should return empty array when no members found', async () => {
|
|
358
|
+
api.members.browse.mockResolvedValue([]);
|
|
359
|
+
|
|
360
|
+
const result = await getMembers();
|
|
361
|
+
|
|
362
|
+
expect(result).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should handle Ghost API errors', async () => {
|
|
366
|
+
api.members.browse.mockRejectedValue(new Error('Ghost API Error'));
|
|
367
|
+
|
|
368
|
+
await expect(getMembers()).rejects.toThrow();
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('getMember', () => {
|
|
373
|
+
it('should get member by ID', async () => {
|
|
374
|
+
const mockMember = {
|
|
375
|
+
id: 'member-1',
|
|
376
|
+
email: 'test@example.com',
|
|
377
|
+
name: 'John Doe',
|
|
378
|
+
status: 'free',
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
api.members.read.mockResolvedValue(mockMember);
|
|
382
|
+
|
|
383
|
+
const result = await getMember({ id: 'member-1' });
|
|
384
|
+
|
|
385
|
+
expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: 'member-1' });
|
|
386
|
+
expect(result).toEqual(mockMember);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should get member by email', async () => {
|
|
390
|
+
const mockMember = {
|
|
391
|
+
id: 'member-1',
|
|
392
|
+
email: 'test@example.com',
|
|
393
|
+
name: 'John Doe',
|
|
394
|
+
status: 'free',
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
api.members.browse.mockResolvedValue([mockMember]);
|
|
398
|
+
|
|
399
|
+
const result = await getMember({ email: 'test@example.com' });
|
|
400
|
+
|
|
401
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
402
|
+
expect.objectContaining({
|
|
403
|
+
filter: expect.stringContaining('test@example.com'),
|
|
404
|
+
}),
|
|
405
|
+
expect.any(Object)
|
|
406
|
+
);
|
|
407
|
+
expect(result).toEqual(mockMember);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should throw validation error when neither id nor email provided', async () => {
|
|
411
|
+
await expect(getMember({})).rejects.toThrow('Member lookup validation failed');
|
|
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
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should throw not found error when member not found by ID', async () => {
|
|
421
|
+
api.members.read.mockRejectedValue({
|
|
422
|
+
response: { status: 404 },
|
|
423
|
+
message: 'Member not found',
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await expect(getMember({ id: 'non-existent' })).rejects.toThrow();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should throw not found error when member not found by email', async () => {
|
|
430
|
+
api.members.browse.mockResolvedValue([]);
|
|
431
|
+
|
|
432
|
+
await expect(getMember({ email: 'notfound@example.com' })).rejects.toThrow(
|
|
433
|
+
'Member not found'
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should prioritize ID over email when both provided', async () => {
|
|
438
|
+
const mockMember = {
|
|
439
|
+
id: 'member-1',
|
|
440
|
+
email: 'test@example.com',
|
|
441
|
+
status: 'free',
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
api.members.read.mockResolvedValue(mockMember);
|
|
445
|
+
|
|
446
|
+
await getMember({ id: 'member-1', email: 'test@example.com' });
|
|
447
|
+
|
|
448
|
+
// Should use read (ID) instead of browse (email)
|
|
449
|
+
expect(api.members.read).toHaveBeenCalled();
|
|
450
|
+
expect(api.members.browse).not.toHaveBeenCalled();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe('searchMembers', () => {
|
|
455
|
+
it('should search members by query', async () => {
|
|
456
|
+
const mockMembers = [{ id: 'member-1', email: 'john@example.com', name: 'John Doe' }];
|
|
457
|
+
|
|
458
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
459
|
+
|
|
460
|
+
const result = await searchMembers('john');
|
|
461
|
+
|
|
462
|
+
expect(api.members.browse).toHaveBeenCalled();
|
|
463
|
+
expect(result).toEqual(mockMembers);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should apply default limit of 15', async () => {
|
|
467
|
+
const mockMembers = [];
|
|
468
|
+
|
|
469
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
470
|
+
|
|
471
|
+
await searchMembers('test');
|
|
472
|
+
|
|
473
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
474
|
+
expect.objectContaining({
|
|
475
|
+
limit: 15,
|
|
476
|
+
}),
|
|
477
|
+
expect.any(Object)
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('should accept custom limit', async () => {
|
|
482
|
+
const mockMembers = [];
|
|
483
|
+
|
|
484
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
485
|
+
|
|
486
|
+
await searchMembers('test', { limit: 25 });
|
|
487
|
+
|
|
488
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
489
|
+
expect.objectContaining({
|
|
490
|
+
limit: 25,
|
|
491
|
+
}),
|
|
492
|
+
expect.any(Object)
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should throw validation error for empty query', async () => {
|
|
497
|
+
await expect(searchMembers('')).rejects.toThrow('Search query validation failed');
|
|
498
|
+
await expect(searchMembers(' ')).rejects.toThrow('Search query validation failed');
|
|
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
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should sanitize query to prevent NQL injection', async () => {
|
|
519
|
+
const mockMembers = [];
|
|
520
|
+
|
|
521
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
522
|
+
|
|
523
|
+
// Query with special NQL characters
|
|
524
|
+
await searchMembers("test'value");
|
|
525
|
+
|
|
526
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
527
|
+
expect.objectContaining({
|
|
528
|
+
filter: expect.stringContaining("\\'"),
|
|
529
|
+
}),
|
|
530
|
+
expect.any(Object)
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should return empty array when no matches found', async () => {
|
|
535
|
+
api.members.browse.mockResolvedValue([]);
|
|
536
|
+
|
|
537
|
+
const result = await searchMembers('nonexistent');
|
|
538
|
+
|
|
539
|
+
expect(result).toEqual([]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should handle Ghost API errors', async () => {
|
|
543
|
+
api.members.browse.mockRejectedValue(new Error('Ghost API Error'));
|
|
544
|
+
|
|
545
|
+
await expect(searchMembers('test')).rejects.toThrow();
|
|
546
|
+
});
|
|
547
|
+
});
|
|
261
548
|
});
|