@jgardner04/ghost-mcp-server 1.13.0 → 1.13.1
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 +2 -2
- 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/tagSchemas.js +33 -5
- package/src/services/__tests__/ghostService.test.js +30 -23
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +475 -0
- package/src/services/ghostService.js +21 -18
- package/src/services/ghostServiceImproved.js +16 -10
|
@@ -172,6 +172,106 @@ describe('Tag Schemas', () => {
|
|
|
172
172
|
expect(result).toBeDefined();
|
|
173
173
|
// Note: optional fields with defaults don't apply when field is omitted
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
it('should accept limit as "all" string', () => {
|
|
177
|
+
const query = {
|
|
178
|
+
limit: 'all',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const result = tagQuerySchema.parse(query);
|
|
182
|
+
expect(result.limit).toBe('all');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should accept limit as number within range', () => {
|
|
186
|
+
const query = {
|
|
187
|
+
limit: 50,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = tagQuerySchema.parse(query);
|
|
191
|
+
expect(result.limit).toBe(50);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should reject limit as invalid string', () => {
|
|
195
|
+
const query = {
|
|
196
|
+
limit: 'invalid',
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
expect(() => tagQuerySchema.parse(query)).toThrow();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should reject limit greater than 100', () => {
|
|
203
|
+
const query = {
|
|
204
|
+
limit: 101,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
expect(() => tagQuerySchema.parse(query)).toThrow();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should reject limit less than 1', () => {
|
|
211
|
+
const query = {
|
|
212
|
+
limit: 0,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
expect(() => tagQuerySchema.parse(query)).toThrow();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should accept limit as string number and transform to number', () => {
|
|
219
|
+
const query = {
|
|
220
|
+
limit: '50',
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const result = tagQuerySchema.parse(query);
|
|
224
|
+
expect(result.limit).toBe(50);
|
|
225
|
+
expect(typeof result.limit).toBe('number');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should accept page as string number and transform to number', () => {
|
|
229
|
+
const query = {
|
|
230
|
+
page: '3',
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const result = tagQuerySchema.parse(query);
|
|
234
|
+
expect(result.page).toBe(3);
|
|
235
|
+
expect(typeof result.page).toBe('number');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should reject query with both name and filter parameters', () => {
|
|
239
|
+
const query = {
|
|
240
|
+
name: 'Technology',
|
|
241
|
+
filter: 'visibility:public',
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
expect(() => tagQuerySchema.parse(query)).toThrow();
|
|
245
|
+
try {
|
|
246
|
+
tagQuerySchema.parse(query);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
expect(error.errors[0].message).toContain('Cannot specify both "name" and "filter"');
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should reject name with invalid characters', () => {
|
|
253
|
+
const query = {
|
|
254
|
+
name: 'test;DROP',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
expect(() => tagQuerySchema.parse(query)).toThrow(/invalid characters/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should accept name with apostrophe', () => {
|
|
261
|
+
const query = {
|
|
262
|
+
name: "O'Reilly",
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
expect(() => tagQuerySchema.parse(query)).not.toThrow();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should accept name with spaces, hyphens, and underscores', () => {
|
|
269
|
+
const query = {
|
|
270
|
+
name: 'Tech News 2024-Web_Dev',
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
expect(() => tagQuerySchema.parse(query)).not.toThrow();
|
|
274
|
+
});
|
|
175
275
|
});
|
|
176
276
|
|
|
177
277
|
describe('tagIdSchema', () => {
|
|
@@ -57,14 +57,33 @@ export const createTagSchema = z.object({
|
|
|
57
57
|
export const updateTagSchema = createTagSchema.partial();
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
60
|
+
* Base schema for tag query/filter parameters (without refinement)
|
|
61
|
+
* Exported for use in MCP server where .partial() is needed
|
|
61
62
|
*/
|
|
62
|
-
export const
|
|
63
|
-
name: z
|
|
63
|
+
export const tagQueryBaseSchema = z.object({
|
|
64
|
+
name: z
|
|
65
|
+
.string()
|
|
66
|
+
.regex(
|
|
67
|
+
/^[a-zA-Z0-9\s\-_']+$/,
|
|
68
|
+
'Tag name contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, and apostrophes are allowed'
|
|
69
|
+
)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Filter by exact tag name (legacy parameter, converted to filter internally)'),
|
|
64
72
|
slug: z.string().optional().describe('Filter by tag slug'),
|
|
65
73
|
visibility: visibilitySchema.optional().describe('Filter by visibility'),
|
|
66
|
-
limit: z
|
|
67
|
-
|
|
74
|
+
limit: z
|
|
75
|
+
.union([
|
|
76
|
+
z.number().int().min(1).max(100),
|
|
77
|
+
z.string().regex(/^\d+$/).transform(Number),
|
|
78
|
+
z.literal('all'),
|
|
79
|
+
])
|
|
80
|
+
.default(15)
|
|
81
|
+
.optional()
|
|
82
|
+
.describe('Number of tags to return (1-100) or "all" for all tags'),
|
|
83
|
+
page: z
|
|
84
|
+
.union([z.number().int().min(1), z.string().regex(/^\d+$/).transform(Number)])
|
|
85
|
+
.default(1)
|
|
86
|
+
.optional(),
|
|
68
87
|
filter: z
|
|
69
88
|
.string()
|
|
70
89
|
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
|
|
@@ -77,6 +96,15 @@ export const tagQuerySchema = z.object({
|
|
|
77
96
|
order: z.string().optional().describe('Order results (e.g., "name ASC", "created_at DESC")'),
|
|
78
97
|
});
|
|
79
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Schema for tag query/filter parameters with validation
|
|
101
|
+
* Note: Only one of 'name' or 'filter' should be provided, not both
|
|
102
|
+
*/
|
|
103
|
+
export const tagQuerySchema = tagQueryBaseSchema.refine((data) => !(data.name && data.filter), {
|
|
104
|
+
message: 'Cannot specify both "name" and "filter" parameters. Use "filter" for advanced queries.',
|
|
105
|
+
path: ['filter'],
|
|
106
|
+
});
|
|
107
|
+
|
|
80
108
|
/**
|
|
81
109
|
* Schema for tag ID parameter
|
|
82
110
|
*/
|
|
@@ -276,24 +276,7 @@ describe('ghostService', () => {
|
|
|
276
276
|
});
|
|
277
277
|
|
|
278
278
|
describe('getTags', () => {
|
|
279
|
-
it('should
|
|
280
|
-
await expect(getTags("'; DROP TABLE tags; --")).rejects.toThrow(
|
|
281
|
-
'Tag name contains invalid characters'
|
|
282
|
-
);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it('should accept valid tag names', async () => {
|
|
286
|
-
const validNames = ['Test Tag', 'test-tag', 'test_tag', 'Tag123'];
|
|
287
|
-
const expectedTags = [{ id: '1', name: 'Tag' }];
|
|
288
|
-
api.tags.browse.mockResolvedValue(expectedTags);
|
|
289
|
-
|
|
290
|
-
for (const name of validNames) {
|
|
291
|
-
const result = await getTags(name);
|
|
292
|
-
expect(result).toEqual(expectedTags);
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it('should handle tags without filter when name is not provided', async () => {
|
|
279
|
+
it('should get all tags when no options provided', async () => {
|
|
297
280
|
const expectedTags = [
|
|
298
281
|
{ id: '1', name: 'Tag1' },
|
|
299
282
|
{ id: '2', name: 'Tag2' },
|
|
@@ -303,17 +286,41 @@ describe('ghostService', () => {
|
|
|
303
286
|
const result = await getTags();
|
|
304
287
|
|
|
305
288
|
expect(result).toEqual(expectedTags);
|
|
306
|
-
expect(api.tags.browse).toHaveBeenCalledWith({ limit:
|
|
289
|
+
expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15 }, {});
|
|
307
290
|
});
|
|
308
291
|
|
|
309
|
-
it('should
|
|
310
|
-
const expectedTags = [{ id: '1', name:
|
|
292
|
+
it('should pass options to browse endpoint', async () => {
|
|
293
|
+
const expectedTags = [{ id: '1', name: 'Tag' }];
|
|
311
294
|
api.tags.browse.mockResolvedValue(expectedTags);
|
|
312
295
|
|
|
313
|
-
|
|
314
|
-
const result = await getTags('Valid Tag');
|
|
296
|
+
const result = await getTags({ limit: 5, filter: "name:'Test'" });
|
|
315
297
|
|
|
316
298
|
expect(result).toEqual(expectedTags);
|
|
299
|
+
expect(api.tags.browse).toHaveBeenCalledWith({ limit: 5, filter: "name:'Test'" }, {});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should return empty array on null response', async () => {
|
|
303
|
+
api.tags.browse.mockResolvedValue(null);
|
|
304
|
+
|
|
305
|
+
const result = await getTags();
|
|
306
|
+
|
|
307
|
+
expect(result).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should handle errors and rethrow', async () => {
|
|
311
|
+
const error = new Error('API error');
|
|
312
|
+
api.tags.browse.mockRejectedValue(error);
|
|
313
|
+
|
|
314
|
+
await expect(getTags()).rejects.toThrow('API error');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should merge default limit with custom options', async () => {
|
|
318
|
+
const expectedTags = [{ id: '1', name: 'Tag' }];
|
|
319
|
+
api.tags.browse.mockResolvedValue(expectedTags);
|
|
320
|
+
|
|
321
|
+
await getTags({ filter: "name:'Custom'" });
|
|
322
|
+
|
|
323
|
+
expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15, filter: "name:'Custom'" }, {});
|
|
317
324
|
});
|
|
318
325
|
});
|
|
319
326
|
});
|
|
@@ -0,0 +1,475 @@
|
|
|
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 update tag with valid ID and data', 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
|
+
};
|
|
418
|
+
|
|
419
|
+
const mockUpdatedTag = {
|
|
420
|
+
...mockExistingTag,
|
|
421
|
+
...updateData,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
api.tags.read.mockResolvedValue(mockExistingTag);
|
|
425
|
+
api.tags.edit.mockResolvedValue(mockUpdatedTag);
|
|
426
|
+
|
|
427
|
+
const result = await updateTag(tagId, updateData);
|
|
428
|
+
|
|
429
|
+
expect(api.tags.read).toHaveBeenCalled();
|
|
430
|
+
expect(api.tags.edit).toHaveBeenCalled();
|
|
431
|
+
expect(result).toEqual(mockUpdatedTag);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('should throw validation error for missing tag ID', async () => {
|
|
435
|
+
await expect(updateTag(null, { name: 'Test' })).rejects.toThrow(
|
|
436
|
+
'Tag ID is required for update'
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should throw not found error if tag does not exist', async () => {
|
|
441
|
+
api.tags.read.mockRejectedValue({
|
|
442
|
+
response: { status: 404 },
|
|
443
|
+
message: 'Tag not found',
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await expect(updateTag('non-existent', { name: 'Test' })).rejects.toThrow();
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('deleteTag', () => {
|
|
451
|
+
it('should delete tag with valid ID', async () => {
|
|
452
|
+
const tagId = 'tag-1';
|
|
453
|
+
|
|
454
|
+
api.tags.delete.mockResolvedValue({ deleted: true });
|
|
455
|
+
|
|
456
|
+
const result = await deleteTag(tagId);
|
|
457
|
+
|
|
458
|
+
expect(api.tags.delete).toHaveBeenCalledWith(tagId, expect.any(Object));
|
|
459
|
+
expect(result).toEqual({ deleted: true });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should throw validation error for missing tag ID', async () => {
|
|
463
|
+
await expect(deleteTag(null)).rejects.toThrow('Tag ID is required for deletion');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should throw not found error if tag does not exist', async () => {
|
|
467
|
+
api.tags.delete.mockRejectedValue({
|
|
468
|
+
response: { status: 404 },
|
|
469
|
+
message: 'Tag not found',
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
await expect(deleteTag('non-existent')).rejects.toThrow();
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
});
|