@jgardner04/ghost-mcp-server 1.12.5 → 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.
@@ -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
- * Schema for tag query/filter parameters
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 tagQuerySchema = z.object({
63
- name: z.string().optional().describe('Filter by exact tag name'),
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.number().int().min(1).max(100).default(15).optional(),
67
- page: z.number().int().min(1).default(1).optional(),
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 reject tag names with invalid characters', async () => {
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: 'all' }, {});
289
+ expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15 }, {});
307
290
  });
308
291
 
309
- it('should properly escape tag names in filter', async () => {
310
- const expectedTags = [{ id: '1', name: "Tag's 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
- // This should work because we properly escape single quotes
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
+ });