@jgardner04/ghost-mcp-server 1.13.0 → 1.13.2
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 +3 -7
- package/src/__tests__/mcp_server.test.js +258 -0
- package/src/__tests__/mcp_server_pages.test.js +21 -6
- package/src/controllers/__tests__/tagController.test.js +118 -3
- package/src/controllers/tagController.js +32 -6
- package/src/mcp_server.js +225 -112
- package/src/resources/ResourceManager.js +6 -2
- package/src/resources/__tests__/ResourceManager.test.js +2 -2
- package/src/schemas/__tests__/tagSchemas.test.js +100 -0
- package/src/schemas/pageSchemas.js +0 -2
- package/src/schemas/postSchemas.js +6 -4
- package/src/schemas/tagSchemas.js +33 -5
- package/src/services/__tests__/ghostService.test.js +30 -23
- package/src/services/__tests__/ghostServiceImproved.members.test.js +9 -5
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +16 -11
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +56 -13
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +233 -0
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +486 -0
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -8
- package/src/services/ghostService.js +21 -18
- package/src/services/ghostServiceImproved.js +77 -194
- package/src/utils/__tests__/urlValidator.test.js +137 -1
- package/src/utils/urlValidator.js +25 -2
|
@@ -0,0 +1,486 @@
|
|
|
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 tags 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
|
+
site: {
|
|
38
|
+
read: vi.fn(),
|
|
39
|
+
},
|
|
40
|
+
images: {
|
|
41
|
+
upload: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// Mock dotenv
|
|
48
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
49
|
+
|
|
50
|
+
// Mock logger
|
|
51
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
52
|
+
createContextLogger: createMockContextLogger(),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Mock fs for validateImagePath
|
|
56
|
+
vi.mock('fs/promises', () => ({
|
|
57
|
+
default: {
|
|
58
|
+
access: vi.fn(),
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Import after setting up mocks
|
|
63
|
+
import {
|
|
64
|
+
getTags,
|
|
65
|
+
getTag,
|
|
66
|
+
createTag,
|
|
67
|
+
updateTag,
|
|
68
|
+
deleteTag,
|
|
69
|
+
api,
|
|
70
|
+
ghostCircuitBreaker,
|
|
71
|
+
} from '../ghostServiceImproved.js';
|
|
72
|
+
|
|
73
|
+
describe('ghostServiceImproved - Tags', () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
// Reset all mocks before each test
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
|
|
78
|
+
// Reset circuit breaker to closed state
|
|
79
|
+
if (ghostCircuitBreaker) {
|
|
80
|
+
ghostCircuitBreaker.state = 'CLOSED';
|
|
81
|
+
ghostCircuitBreaker.failureCount = 0;
|
|
82
|
+
ghostCircuitBreaker.lastFailureTime = null;
|
|
83
|
+
ghostCircuitBreaker.nextAttempt = null;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('getTags', () => {
|
|
88
|
+
it('should return all tags with default options', async () => {
|
|
89
|
+
const mockTags = [
|
|
90
|
+
{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' },
|
|
91
|
+
{ id: 'tag-2', name: 'TypeScript', slug: 'typescript' },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
95
|
+
|
|
96
|
+
const result = await getTags();
|
|
97
|
+
|
|
98
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
limit: 15,
|
|
101
|
+
}),
|
|
102
|
+
expect.any(Object)
|
|
103
|
+
);
|
|
104
|
+
expect(result).toEqual(mockTags);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should accept custom limit option', async () => {
|
|
108
|
+
const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
|
|
109
|
+
|
|
110
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
111
|
+
|
|
112
|
+
await getTags({ limit: 50 });
|
|
113
|
+
|
|
114
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
115
|
+
expect.objectContaining({
|
|
116
|
+
limit: 50,
|
|
117
|
+
}),
|
|
118
|
+
expect.any(Object)
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should accept pagination options (page)', async () => {
|
|
123
|
+
const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
|
|
124
|
+
|
|
125
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
126
|
+
|
|
127
|
+
await getTags({ limit: 20, page: 2 });
|
|
128
|
+
|
|
129
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
limit: 20,
|
|
132
|
+
page: 2,
|
|
133
|
+
}),
|
|
134
|
+
expect.any(Object)
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should accept filter options', async () => {
|
|
139
|
+
const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
|
|
140
|
+
|
|
141
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
142
|
+
|
|
143
|
+
await getTags({ filter: "name:'JavaScript'" });
|
|
144
|
+
|
|
145
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
limit: 15,
|
|
148
|
+
filter: "name:'JavaScript'",
|
|
149
|
+
}),
|
|
150
|
+
expect.any(Object)
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should accept order options', async () => {
|
|
155
|
+
const mockTags = [
|
|
156
|
+
{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' },
|
|
157
|
+
{ id: 'tag-2', name: 'TypeScript', slug: 'typescript' },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
161
|
+
|
|
162
|
+
await getTags({ order: 'name asc' });
|
|
163
|
+
|
|
164
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
165
|
+
expect.objectContaining({
|
|
166
|
+
limit: 15,
|
|
167
|
+
order: 'name asc',
|
|
168
|
+
}),
|
|
169
|
+
expect.any(Object)
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should accept include options', async () => {
|
|
174
|
+
const mockTags = [
|
|
175
|
+
{ id: 'tag-1', name: 'JavaScript', slug: 'javascript', count: { posts: 10 } },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
179
|
+
|
|
180
|
+
await getTags({ include: 'count.posts' });
|
|
181
|
+
|
|
182
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
183
|
+
expect.objectContaining({
|
|
184
|
+
limit: 15,
|
|
185
|
+
include: 'count.posts',
|
|
186
|
+
}),
|
|
187
|
+
expect.any(Object)
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should accept multiple options together', async () => {
|
|
192
|
+
const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
|
|
193
|
+
|
|
194
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
195
|
+
|
|
196
|
+
await getTags({
|
|
197
|
+
limit: 25,
|
|
198
|
+
page: 3,
|
|
199
|
+
filter: 'visibility:public',
|
|
200
|
+
order: 'slug desc',
|
|
201
|
+
include: 'count.posts',
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
205
|
+
expect.objectContaining({
|
|
206
|
+
limit: 25,
|
|
207
|
+
page: 3,
|
|
208
|
+
filter: 'visibility:public',
|
|
209
|
+
order: 'slug desc',
|
|
210
|
+
include: 'count.posts',
|
|
211
|
+
}),
|
|
212
|
+
expect.any(Object)
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should return empty array when no tags found', async () => {
|
|
217
|
+
api.tags.browse.mockResolvedValue([]);
|
|
218
|
+
|
|
219
|
+
const result = await getTags();
|
|
220
|
+
|
|
221
|
+
expect(result).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should return empty array when API returns null', async () => {
|
|
225
|
+
api.tags.browse.mockResolvedValue(null);
|
|
226
|
+
|
|
227
|
+
const result = await getTags();
|
|
228
|
+
|
|
229
|
+
expect(result).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should handle Ghost API errors', async () => {
|
|
233
|
+
const apiError = new Error('Ghost API Error');
|
|
234
|
+
api.tags.browse.mockRejectedValue(apiError);
|
|
235
|
+
|
|
236
|
+
// Errors are wrapped by handleApiRequest with "External service error: Ghost API"
|
|
237
|
+
await expect(getTags()).rejects.toThrow(/External service error: Ghost API/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should handle network errors with retry logic', async () => {
|
|
241
|
+
const networkError = new Error('Network timeout');
|
|
242
|
+
api.tags.browse.mockRejectedValue(networkError);
|
|
243
|
+
|
|
244
|
+
// Network errors are wrapped by handleApiRequest
|
|
245
|
+
await expect(getTags()).rejects.toThrow(/External service error: Ghost API/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should reject requests when circuit breaker is open', async () => {
|
|
249
|
+
// Force circuit breaker to open state
|
|
250
|
+
ghostCircuitBreaker.state = 'OPEN';
|
|
251
|
+
ghostCircuitBreaker.nextAttempt = Date.now() + 60000;
|
|
252
|
+
|
|
253
|
+
await expect(getTags()).rejects.toThrow(/circuit.*open/i);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should succeed when circuit breaker is closed', async () => {
|
|
257
|
+
const mockTags = [{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }];
|
|
258
|
+
|
|
259
|
+
// Ensure circuit breaker is closed
|
|
260
|
+
ghostCircuitBreaker.state = 'CLOSED';
|
|
261
|
+
api.tags.browse.mockResolvedValue(mockTags);
|
|
262
|
+
|
|
263
|
+
const result = await getTags();
|
|
264
|
+
|
|
265
|
+
expect(result).toEqual(mockTags);
|
|
266
|
+
expect(api.tags.browse).toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('getTag', () => {
|
|
271
|
+
it('should get tag by ID', async () => {
|
|
272
|
+
const mockTag = {
|
|
273
|
+
id: 'tag-1',
|
|
274
|
+
name: 'JavaScript',
|
|
275
|
+
slug: 'javascript',
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
api.tags.read.mockResolvedValue(mockTag);
|
|
279
|
+
|
|
280
|
+
const result = await getTag('tag-1');
|
|
281
|
+
|
|
282
|
+
expect(api.tags.read).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({}));
|
|
283
|
+
expect(result).toEqual(mockTag);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should throw validation error for missing tag ID', async () => {
|
|
287
|
+
await expect(getTag(null)).rejects.toThrow('Tag ID is required');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should throw not found error when tag does not exist', async () => {
|
|
291
|
+
api.tags.read.mockRejectedValue({
|
|
292
|
+
response: { status: 404 },
|
|
293
|
+
message: 'Tag not found',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await expect(getTag('non-existent')).rejects.toThrow();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('createTag', () => {
|
|
301
|
+
it('should create a tag with required name', async () => {
|
|
302
|
+
const tagData = {
|
|
303
|
+
name: 'JavaScript',
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const mockCreatedTag = {
|
|
307
|
+
id: 'tag-1',
|
|
308
|
+
name: 'JavaScript',
|
|
309
|
+
slug: 'javascript',
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
api.tags.add.mockResolvedValue(mockCreatedTag);
|
|
313
|
+
|
|
314
|
+
const result = await createTag(tagData);
|
|
315
|
+
|
|
316
|
+
expect(api.tags.add).toHaveBeenCalled();
|
|
317
|
+
expect(result).toEqual(mockCreatedTag);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should throw validation error for missing tag name', async () => {
|
|
321
|
+
await expect(createTag({})).rejects.toThrow('Tag validation failed');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should throw validation error for empty tag name', async () => {
|
|
325
|
+
await expect(createTag({ name: ' ' })).rejects.toThrow('Tag validation failed');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should throw validation error for invalid slug format', async () => {
|
|
329
|
+
await expect(createTag({ name: 'Test', slug: 'INVALID_SLUG!' })).rejects.toThrow(
|
|
330
|
+
'Tag validation failed'
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should accept valid slug with lowercase letters, numbers, and hyphens', async () => {
|
|
335
|
+
const tagData = {
|
|
336
|
+
name: 'Test Tag',
|
|
337
|
+
slug: 'valid-slug-123',
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const mockCreatedTag = {
|
|
341
|
+
id: 'tag-1',
|
|
342
|
+
name: 'Test Tag',
|
|
343
|
+
slug: 'valid-slug-123',
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
api.tags.add.mockResolvedValue(mockCreatedTag);
|
|
347
|
+
|
|
348
|
+
const result = await createTag(tagData);
|
|
349
|
+
|
|
350
|
+
expect(result).toEqual(mockCreatedTag);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should auto-generate slug if not provided', async () => {
|
|
354
|
+
const tagData = {
|
|
355
|
+
name: 'JavaScript & TypeScript',
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const mockCreatedTag = {
|
|
359
|
+
id: 'tag-1',
|
|
360
|
+
name: 'JavaScript & TypeScript',
|
|
361
|
+
slug: 'javascript-typescript',
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
api.tags.add.mockResolvedValue(mockCreatedTag);
|
|
365
|
+
|
|
366
|
+
await createTag(tagData);
|
|
367
|
+
|
|
368
|
+
// Verify that slug is auto-generated
|
|
369
|
+
expect(api.tags.add).toHaveBeenCalledWith(
|
|
370
|
+
expect.objectContaining({
|
|
371
|
+
name: 'JavaScript & TypeScript',
|
|
372
|
+
slug: expect.stringMatching(/javascript-typescript/),
|
|
373
|
+
}),
|
|
374
|
+
expect.any(Object)
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should handle duplicate tag errors gracefully', async () => {
|
|
379
|
+
const tagData = {
|
|
380
|
+
name: 'JavaScript',
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// First call fails with duplicate error
|
|
384
|
+
api.tags.add.mockRejectedValue({
|
|
385
|
+
response: { status: 422 },
|
|
386
|
+
message: 'Tag already exists',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// getTags returns existing tag when called with name filter
|
|
390
|
+
api.tags.browse.mockResolvedValue([{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }]);
|
|
391
|
+
|
|
392
|
+
const result = await createTag(tagData);
|
|
393
|
+
|
|
394
|
+
// Verify getTags was called with correct filter for duplicate lookup
|
|
395
|
+
expect(api.tags.browse).toHaveBeenCalledWith(
|
|
396
|
+
expect.objectContaining({
|
|
397
|
+
filter: "name:'JavaScript'",
|
|
398
|
+
}),
|
|
399
|
+
expect.any(Object)
|
|
400
|
+
);
|
|
401
|
+
expect(result).toEqual({ id: 'tag-1', name: 'JavaScript', slug: 'javascript' });
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('updateTag', () => {
|
|
406
|
+
it('should send only update fields, not the full existing tag', async () => {
|
|
407
|
+
const tagId = 'tag-1';
|
|
408
|
+
const updateData = {
|
|
409
|
+
name: 'Updated JavaScript',
|
|
410
|
+
description: 'Updated description',
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const mockExistingTag = {
|
|
414
|
+
id: tagId,
|
|
415
|
+
name: 'JavaScript',
|
|
416
|
+
slug: 'javascript',
|
|
417
|
+
url: 'https://example.com/tag/javascript',
|
|
418
|
+
visibility: 'public',
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const mockUpdatedTag = {
|
|
422
|
+
...mockExistingTag,
|
|
423
|
+
...updateData,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
api.tags.read.mockResolvedValue(mockExistingTag);
|
|
427
|
+
api.tags.edit.mockResolvedValue(mockUpdatedTag);
|
|
428
|
+
|
|
429
|
+
const result = await updateTag(tagId, updateData);
|
|
430
|
+
|
|
431
|
+
expect(api.tags.read).toHaveBeenCalled();
|
|
432
|
+
// Should send ONLY updateData, NOT the full existing tag
|
|
433
|
+
expect(api.tags.edit).toHaveBeenCalledWith(
|
|
434
|
+
{ name: 'Updated JavaScript', description: 'Updated description' },
|
|
435
|
+
{ id: tagId }
|
|
436
|
+
);
|
|
437
|
+
// Verify read-only fields are NOT sent
|
|
438
|
+
const editCallData = api.tags.edit.mock.calls[0][0];
|
|
439
|
+
expect(editCallData).not.toHaveProperty('url');
|
|
440
|
+
expect(editCallData).not.toHaveProperty('visibility');
|
|
441
|
+
expect(editCallData).not.toHaveProperty('slug');
|
|
442
|
+
expect(result).toEqual(mockUpdatedTag);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('should throw validation error for missing tag ID', async () => {
|
|
446
|
+
await expect(updateTag(null, { name: 'Test' })).rejects.toThrow(
|
|
447
|
+
'Tag ID is required for update'
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should throw not found error if tag does not exist', async () => {
|
|
452
|
+
api.tags.read.mockRejectedValue({
|
|
453
|
+
response: { status: 404 },
|
|
454
|
+
message: 'Tag not found',
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await expect(updateTag('non-existent', { name: 'Test' })).rejects.toThrow();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('deleteTag', () => {
|
|
462
|
+
it('should delete tag with valid ID', async () => {
|
|
463
|
+
const tagId = 'tag-1';
|
|
464
|
+
|
|
465
|
+
api.tags.delete.mockResolvedValue({ deleted: true });
|
|
466
|
+
|
|
467
|
+
const result = await deleteTag(tagId);
|
|
468
|
+
|
|
469
|
+
expect(api.tags.delete).toHaveBeenCalledWith(tagId, expect.any(Object));
|
|
470
|
+
expect(result).toEqual({ deleted: true });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should throw validation error for missing tag ID', async () => {
|
|
474
|
+
await expect(deleteTag(null)).rejects.toThrow('Tag ID is required for deletion');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should throw not found error if tag does not exist', async () => {
|
|
478
|
+
api.tags.delete.mockRejectedValue({
|
|
479
|
+
response: { status: 404 },
|
|
480
|
+
message: 'Tag not found',
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
await expect(deleteTag('non-existent')).rejects.toThrow();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
});
|
|
@@ -300,12 +300,15 @@ describe('ghostServiceImproved - Tiers', () => {
|
|
|
300
300
|
});
|
|
301
301
|
|
|
302
302
|
describe('updateTier', () => {
|
|
303
|
-
it('should update
|
|
303
|
+
it('should send only update fields and updated_at, not the full existing tier', async () => {
|
|
304
304
|
const existingTier = {
|
|
305
305
|
id: 'tier-1',
|
|
306
|
+
slug: 'premium',
|
|
306
307
|
name: 'Premium',
|
|
307
308
|
currency: 'USD',
|
|
308
309
|
monthly_price: 999,
|
|
310
|
+
type: 'paid',
|
|
311
|
+
active: true,
|
|
309
312
|
updated_at: '2024-01-01T00:00:00.000Z',
|
|
310
313
|
};
|
|
311
314
|
|
|
@@ -328,15 +331,16 @@ describe('ghostServiceImproved - Tiers', () => {
|
|
|
328
331
|
expect.objectContaining({ id: 'tier-1' }),
|
|
329
332
|
expect.objectContaining({ id: 'tier-1' })
|
|
330
333
|
);
|
|
334
|
+
// Should send ONLY updateData + updated_at, NOT the full existing tier
|
|
331
335
|
expect(api.tiers.edit).toHaveBeenCalledWith(
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
...updateData,
|
|
335
|
-
}),
|
|
336
|
-
expect.objectContaining({
|
|
337
|
-
id: 'tier-1',
|
|
338
|
-
})
|
|
336
|
+
{ name: 'Premium Plus', monthly_price: 1299, updated_at: '2024-01-01T00:00:00.000Z' },
|
|
337
|
+
expect.objectContaining({ id: 'tier-1' })
|
|
339
338
|
);
|
|
339
|
+
// Verify read-only fields are NOT sent
|
|
340
|
+
const editCallData = api.tiers.edit.mock.calls[0][0];
|
|
341
|
+
expect(editCallData).not.toHaveProperty('slug');
|
|
342
|
+
expect(editCallData).not.toHaveProperty('type');
|
|
343
|
+
expect(editCallData).not.toHaveProperty('active');
|
|
340
344
|
expect(result).toEqual(mockUpdatedTier);
|
|
341
345
|
});
|
|
342
346
|
|
|
@@ -180,27 +180,30 @@ const createTag = async (tagData) => {
|
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
* Retrieves tags from Ghost
|
|
184
|
-
* @param {
|
|
183
|
+
* Retrieves tags from Ghost with optional filtering.
|
|
184
|
+
* @param {object} [options={}] - Query options for filtering and pagination.
|
|
185
|
+
* @param {number} [options.limit] - Maximum number of tags to return (default: 15).
|
|
186
|
+
* @param {string} [options.filter] - NQL filter string for advanced filtering.
|
|
187
|
+
* @param {string} [options.order] - Order string for sorting results.
|
|
188
|
+
* @param {string} [options.include] - Include string for related data.
|
|
185
189
|
* @returns {Promise<Array<object>>} An array of tag objects.
|
|
186
190
|
*/
|
|
187
|
-
const getTags = async (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
191
|
+
const getTags = async (options = {}) => {
|
|
192
|
+
try {
|
|
193
|
+
const tags = await handleApiRequest(
|
|
194
|
+
'tags',
|
|
195
|
+
'browse',
|
|
196
|
+
{},
|
|
197
|
+
{
|
|
198
|
+
limit: 15,
|
|
199
|
+
...options,
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
return tags || [];
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger.error('Failed to get tags', { error: error.message });
|
|
205
|
+
throw error;
|
|
201
206
|
}
|
|
202
|
-
|
|
203
|
-
return handleApiRequest('tags', 'browse', {}, options);
|
|
204
207
|
};
|
|
205
208
|
|
|
206
209
|
// Add other content management functions here (createTag, etc.)
|