@wordpress/abilities 0.1.1-next.dc3f6d3c1.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE.md +788 -0
  3. package/README.md +302 -0
  4. package/build/api.js +189 -0
  5. package/build/api.js.map +7 -0
  6. package/build/index.js +53 -0
  7. package/build/index.js.map +7 -0
  8. package/build/store/actions.js +183 -0
  9. package/build/store/actions.js.map +7 -0
  10. package/build/store/constants.js +58 -0
  11. package/build/store/constants.js.map +7 -0
  12. package/build/store/index.js +77 -0
  13. package/build/store/index.js.map +7 -0
  14. package/build/store/reducer.js +126 -0
  15. package/build/store/reducer.js.map +7 -0
  16. package/build/store/resolvers.js +107 -0
  17. package/build/store/resolvers.js.map +7 -0
  18. package/build/store/selectors.js +64 -0
  19. package/build/store/selectors.js.map +7 -0
  20. package/build/store/tests/actions.test.js +792 -0
  21. package/build/store/tests/actions.test.js.map +7 -0
  22. package/build/store/tests/reducer.test.js +743 -0
  23. package/build/store/tests/reducer.test.js.map +7 -0
  24. package/build/store/tests/resolvers.test.js +520 -0
  25. package/build/store/tests/resolvers.test.js.map +7 -0
  26. package/build/store/tests/selectors.test.js +349 -0
  27. package/build/store/tests/selectors.test.js.map +7 -0
  28. package/build/tests/api.test.js +546 -0
  29. package/build/tests/api.test.js.map +7 -0
  30. package/build/tests/validation.test.js +453 -0
  31. package/build/tests/validation.test.js.map +7 -0
  32. package/build/types.js +19 -0
  33. package/build/types.js.map +7 -0
  34. package/build/validation.js +144 -0
  35. package/build/validation.js.map +7 -0
  36. package/build-module/api.js +146 -0
  37. package/build-module/api.js.map +7 -0
  38. package/build-module/index.js +28 -0
  39. package/build-module/index.js.map +7 -0
  40. package/build-module/store/actions.js +161 -0
  41. package/build-module/store/actions.js.map +7 -0
  42. package/build-module/store/constants.js +24 -0
  43. package/build-module/store/constants.js.map +7 -0
  44. package/build-module/store/index.js +47 -0
  45. package/build-module/store/index.js.map +7 -0
  46. package/build-module/store/reducer.js +112 -0
  47. package/build-module/store/reducer.js.map +7 -0
  48. package/build-module/store/resolvers.js +84 -0
  49. package/build-module/store/resolvers.js.map +7 -0
  50. package/build-module/store/selectors.js +36 -0
  51. package/build-module/store/selectors.js.map +7 -0
  52. package/build-module/store/tests/actions.test.js +804 -0
  53. package/build-module/store/tests/actions.test.js.map +7 -0
  54. package/build-module/store/tests/reducer.test.js +726 -0
  55. package/build-module/store/tests/reducer.test.js.map +7 -0
  56. package/build-module/store/tests/resolvers.test.js +523 -0
  57. package/build-module/store/tests/resolvers.test.js.map +7 -0
  58. package/build-module/store/tests/selectors.test.js +352 -0
  59. package/build-module/store/tests/selectors.test.js.map +7 -0
  60. package/build-module/tests/api.test.js +530 -0
  61. package/build-module/tests/api.test.js.map +7 -0
  62. package/build-module/tests/validation.test.js +451 -0
  63. package/build-module/tests/validation.test.js.map +7 -0
  64. package/build-module/types.js +1 -0
  65. package/build-module/types.js.map +7 -0
  66. package/build-module/validation.js +109 -0
  67. package/build-module/validation.js.map +7 -0
  68. package/build-types/api.d.ts +138 -0
  69. package/build-types/api.d.ts.map +1 -0
  70. package/build-types/index.d.ts +41 -0
  71. package/build-types/index.d.ts.map +1 -0
  72. package/build-types/store/actions.d.ts +76 -0
  73. package/build-types/store/actions.d.ts.map +1 -0
  74. package/build-types/store/constants.d.ts +14 -0
  75. package/build-types/store/constants.d.ts.map +1 -0
  76. package/build-types/store/index.d.ts +7 -0
  77. package/build-types/store/index.d.ts.map +1 -0
  78. package/build-types/store/reducer.d.ts +22 -0
  79. package/build-types/store/reducer.d.ts.map +1 -0
  80. package/build-types/store/resolvers.d.ts +47 -0
  81. package/build-types/store/resolvers.d.ts.map +1 -0
  82. package/build-types/store/selectors.d.ts +37 -0
  83. package/build-types/store/selectors.d.ts.map +1 -0
  84. package/build-types/types.d.ts +163 -0
  85. package/build-types/types.d.ts.map +1 -0
  86. package/build-types/validation.d.ts +23 -0
  87. package/build-types/validation.d.ts.map +1 -0
  88. package/package.json +52 -0
  89. package/src/api.ts +339 -0
  90. package/src/index.ts +65 -0
  91. package/src/store/actions.ts +247 -0
  92. package/src/store/constants.ts +15 -0
  93. package/src/store/index.ts +55 -0
  94. package/src/store/reducer.ts +184 -0
  95. package/src/store/resolvers.ts +151 -0
  96. package/src/store/selectors.ts +82 -0
  97. package/src/store/tests/actions.test.ts +950 -0
  98. package/src/store/tests/reducer.test.ts +861 -0
  99. package/src/store/tests/resolvers.test.ts +642 -0
  100. package/src/store/tests/selectors.test.ts +412 -0
  101. package/src/tests/api.test.ts +655 -0
  102. package/src/tests/validation.test.ts +532 -0
  103. package/src/types.ts +190 -0
  104. package/src/validation.ts +207 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * Tests for API functions.
3
+ */
4
+
5
+ /**
6
+ * WordPress dependencies
7
+ */
8
+ import { dispatch, select, resolveSelect } from '@wordpress/data';
9
+ import apiFetch from '@wordpress/api-fetch';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import {
15
+ getAbilities,
16
+ getAbility,
17
+ getAbilityCategories,
18
+ getAbilityCategory,
19
+ registerAbility,
20
+ unregisterAbility,
21
+ executeAbility,
22
+ } from '../api';
23
+ import { store } from '../store';
24
+ import type { Ability, AbilityCategory } from '../types';
25
+
26
+ // Mock WordPress dependencies
27
+ jest.mock( '@wordpress/data', () => ( {
28
+ dispatch: jest.fn(),
29
+ select: jest.fn(),
30
+ resolveSelect: jest.fn(),
31
+ } ) );
32
+
33
+ jest.mock( '@wordpress/api-fetch' );
34
+
35
+ jest.mock( '../store', () => ( {
36
+ store: 'abilities-api/store',
37
+ } ) );
38
+
39
+ describe( 'API functions', () => {
40
+ beforeEach( () => {
41
+ jest.clearAllMocks();
42
+ } );
43
+
44
+ describe( 'getAbilities', () => {
45
+ it( 'should resolve and return all abilities from the store', async () => {
46
+ const mockAbilities: Ability[] = [
47
+ {
48
+ name: 'test/ability1',
49
+ label: 'Test Ability 1',
50
+ description: 'First test ability',
51
+ category: 'test-category',
52
+ input_schema: { type: 'object' },
53
+ output_schema: { type: 'object' },
54
+ },
55
+ {
56
+ name: 'test/ability2',
57
+ label: 'Test Ability 2',
58
+ description: 'Second test ability',
59
+ category: 'test-category',
60
+ input_schema: { type: 'object' },
61
+ output_schema: { type: 'object' },
62
+ },
63
+ ];
64
+
65
+ const mockGetAbilities = jest
66
+ .fn()
67
+ .mockResolvedValue( mockAbilities );
68
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
69
+ getAbilities: mockGetAbilities,
70
+ } );
71
+
72
+ const result = await getAbilities();
73
+
74
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
75
+ expect( mockGetAbilities ).toHaveBeenCalled();
76
+ expect( result ).toEqual( mockAbilities );
77
+ } );
78
+
79
+ it( 'should pass category parameter to store when filtering', async () => {
80
+ const mockAbilities: Ability[] = [
81
+ {
82
+ name: 'test/ability1',
83
+ label: 'Test Ability 1',
84
+ description: 'First test ability',
85
+ category: 'data-retrieval',
86
+ input_schema: { type: 'object' },
87
+ output_schema: { type: 'object' },
88
+ },
89
+ {
90
+ name: 'test/ability2',
91
+ label: 'Test Ability 2',
92
+ description: 'Second test ability',
93
+ category: 'data-retrieval',
94
+ input_schema: { type: 'object' },
95
+ output_schema: { type: 'object' },
96
+ },
97
+ ];
98
+
99
+ const mockGetAbilities = jest
100
+ .fn()
101
+ .mockResolvedValue( mockAbilities );
102
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
103
+ getAbilities: mockGetAbilities,
104
+ } );
105
+
106
+ const result = await getAbilities( { category: 'data-retrieval' } );
107
+
108
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
109
+ expect( mockGetAbilities ).toHaveBeenCalledWith( {
110
+ category: 'data-retrieval',
111
+ } );
112
+ expect( result ).toEqual( mockAbilities );
113
+ } );
114
+ } );
115
+
116
+ describe( 'getAbility', () => {
117
+ it( 'should return a specific ability by name', async () => {
118
+ const mockAbility: Ability = {
119
+ name: 'test/ability',
120
+ label: 'Test Ability',
121
+ description: 'Test ability description',
122
+ category: 'test-category',
123
+ input_schema: { type: 'object' },
124
+ output_schema: { type: 'object' },
125
+ };
126
+
127
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
128
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
129
+ getAbility: mockGetAbility,
130
+ } );
131
+
132
+ const result = await getAbility( 'test/ability' );
133
+
134
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
135
+ expect( mockGetAbility ).toHaveBeenCalledWith( 'test/ability' );
136
+ expect( result ).toEqual( mockAbility );
137
+ } );
138
+
139
+ it( 'should return null if ability not found', async () => {
140
+ const mockGetAbility = jest.fn().mockResolvedValue( null );
141
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
142
+ getAbility: mockGetAbility,
143
+ } );
144
+
145
+ const result = await getAbility( 'non-existent' );
146
+
147
+ expect( mockGetAbility ).toHaveBeenCalledWith( 'non-existent' );
148
+ expect( result ).toBeNull();
149
+ } );
150
+ } );
151
+
152
+ describe( 'registerAbility', () => {
153
+ it( 'should register a client-side ability with a callback', () => {
154
+ const mockRegisterAbility = jest.fn();
155
+ ( dispatch as jest.Mock ).mockReturnValue( {
156
+ registerAbility: mockRegisterAbility,
157
+ } );
158
+
159
+ // Mock select to return no existing ability
160
+ ( select as jest.Mock ).mockReturnValue( {
161
+ getAbility: jest.fn().mockReturnValue( null ),
162
+ } );
163
+
164
+ const ability = {
165
+ name: 'test/client-ability',
166
+ label: 'Client Ability',
167
+ description: 'Test client ability',
168
+ category: 'test-category',
169
+ input_schema: { type: 'object' },
170
+ output_schema: { type: 'object' },
171
+ callback: jest.fn(),
172
+ };
173
+
174
+ registerAbility( ability );
175
+
176
+ expect( dispatch ).toHaveBeenCalledWith( store );
177
+ expect( mockRegisterAbility ).toHaveBeenCalledWith( ability );
178
+ } );
179
+ } );
180
+
181
+ describe( 'unregisterAbility', () => {
182
+ it( 'should unregister an ability', () => {
183
+ const mockUnregisterAbility = jest.fn();
184
+ ( dispatch as jest.Mock ).mockReturnValue( {
185
+ unregisterAbility: mockUnregisterAbility,
186
+ } );
187
+
188
+ unregisterAbility( 'test/ability' );
189
+
190
+ expect( dispatch ).toHaveBeenCalledWith( store );
191
+ expect( mockUnregisterAbility ).toHaveBeenCalledWith(
192
+ 'test/ability'
193
+ );
194
+ } );
195
+ } );
196
+
197
+ describe( 'executeAbility', () => {
198
+ it( 'should execute a server-side ability via API', async () => {
199
+ const mockAbility: Ability = {
200
+ name: 'test/server-ability',
201
+ label: 'Server Ability',
202
+ description: 'Test server ability',
203
+ category: 'test-category',
204
+ input_schema: {
205
+ type: 'object',
206
+ properties: {
207
+ message: { type: 'string' },
208
+ },
209
+ required: [ 'message' ],
210
+ },
211
+ output_schema: { type: 'object' },
212
+ };
213
+
214
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
215
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
216
+ getAbility: mockGetAbility,
217
+ } );
218
+
219
+ const mockResponse = { success: true, result: 'test' };
220
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
221
+ mockResponse
222
+ );
223
+
224
+ const input = { message: 'Hello' };
225
+ const result = await executeAbility( 'test/server-ability', input );
226
+
227
+ expect( mockGetAbility ).toHaveBeenCalledWith(
228
+ 'test/server-ability'
229
+ );
230
+ expect( apiFetch ).toHaveBeenCalledWith( {
231
+ path: '/wp-abilities/v1/abilities/test/server-ability/run',
232
+ method: 'POST',
233
+ data: { input },
234
+ } );
235
+ expect( result ).toEqual( mockResponse );
236
+ } );
237
+
238
+ it( 'should execute a client-side ability locally', async () => {
239
+ const mockCallback = jest
240
+ .fn()
241
+ .mockResolvedValue( { success: true } );
242
+ const mockAbility: Ability = {
243
+ name: 'test/client-ability',
244
+ label: 'Client Ability',
245
+ description: 'Test client ability',
246
+ category: 'test-category',
247
+ input_schema: { type: 'object' },
248
+ output_schema: { type: 'object' },
249
+ callback: mockCallback,
250
+ };
251
+
252
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
253
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
254
+ getAbility: mockGetAbility,
255
+ } );
256
+
257
+ const input = { test: 'data' };
258
+ const result = await executeAbility( 'test/client-ability', input );
259
+
260
+ expect( mockGetAbility ).toHaveBeenCalledWith(
261
+ 'test/client-ability'
262
+ );
263
+ expect( mockCallback ).toHaveBeenCalledWith( input );
264
+ expect( apiFetch ).not.toHaveBeenCalled();
265
+ expect( result ).toEqual( { success: true } );
266
+ } );
267
+
268
+ it( 'should throw error if ability not found', async () => {
269
+ const mockGetAbility = jest.fn().mockResolvedValue( null );
270
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
271
+ getAbility: mockGetAbility,
272
+ } );
273
+
274
+ await expect(
275
+ executeAbility( 'non-existent', {} )
276
+ ).rejects.toThrow( 'Ability not found: non-existent' );
277
+ } );
278
+
279
+ it( 'should validate input for client abilities', async () => {
280
+ const mockCallback = jest.fn();
281
+ const mockAbility: Ability = {
282
+ name: 'test/client-ability',
283
+ label: 'Client Ability',
284
+ description: 'Test client ability',
285
+ category: 'test-category',
286
+ input_schema: {
287
+ type: 'object',
288
+ properties: {
289
+ message: { type: 'string' },
290
+ },
291
+ required: [ 'message' ],
292
+ },
293
+ output_schema: { type: 'object' },
294
+ callback: mockCallback,
295
+ };
296
+
297
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
298
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
299
+ getAbility: mockGetAbility,
300
+ } );
301
+
302
+ await expect(
303
+ executeAbility( 'test/client-ability', {} )
304
+ ).rejects.toThrow( 'invalid input' );
305
+ } );
306
+
307
+ it( 'should execute a read-only ability via GET', async () => {
308
+ const mockAbility: Ability = {
309
+ name: 'test/read-only',
310
+ label: 'Read-only Ability',
311
+ description: 'Test read-only ability.',
312
+ category: 'test-category',
313
+ input_schema: {
314
+ type: 'object',
315
+ properties: {
316
+ id: { type: 'string' },
317
+ format: { type: 'string' },
318
+ },
319
+ },
320
+ output_schema: { type: 'object' },
321
+ meta: {
322
+ annotations: { readonly: true },
323
+ },
324
+ };
325
+
326
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
327
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
328
+ getAbility: mockGetAbility,
329
+ } );
330
+
331
+ const mockResponse = { data: 'read-only data' };
332
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
333
+ mockResponse
334
+ );
335
+
336
+ const input = { id: '123', format: 'json' };
337
+ const result = await executeAbility( 'test/read-only', input );
338
+
339
+ expect( apiFetch ).toHaveBeenCalledWith( {
340
+ path: '/wp-abilities/v1/abilities/test/read-only/run?input%5Bid%5D=123&input%5Bformat%5D=json',
341
+ method: 'GET',
342
+ } );
343
+ expect( result ).toEqual( mockResponse );
344
+ } );
345
+
346
+ it( 'should execute a read-only ability with empty input', async () => {
347
+ const mockAbility: Ability = {
348
+ name: 'test/read-only',
349
+ label: 'Read-only Ability',
350
+ description: 'Test read-only ability.',
351
+ category: 'test-category',
352
+ input_schema: { type: 'object' },
353
+ output_schema: { type: 'object' },
354
+ meta: {
355
+ annotations: { readonly: true },
356
+ },
357
+ };
358
+
359
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
360
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
361
+ getAbility: mockGetAbility,
362
+ } );
363
+
364
+ const mockResponse = { data: 'read-only data' };
365
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
366
+ mockResponse
367
+ );
368
+
369
+ const result = await executeAbility( 'test/read-only', {} );
370
+
371
+ expect( apiFetch ).toHaveBeenCalledWith( {
372
+ path: '/wp-abilities/v1/abilities/test/read-only/run?',
373
+ method: 'GET',
374
+ } );
375
+ expect( result ).toEqual( mockResponse );
376
+ } );
377
+
378
+ it( 'should execute a destructive idempotent ability via DELETE', async () => {
379
+ const mockAbility: Ability = {
380
+ name: 'test/destructive',
381
+ label: 'Destructive Ability',
382
+ description: 'Test destructive idempotent ability.',
383
+ category: 'test-category',
384
+ input_schema: {
385
+ type: 'object',
386
+ properties: {
387
+ id: { type: 'string' },
388
+ format: { type: 'string' },
389
+ },
390
+ },
391
+ output_schema: { type: 'string' },
392
+ meta: {
393
+ annotations: {
394
+ destructive: true,
395
+ idempotent: true,
396
+ },
397
+ },
398
+ };
399
+
400
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
401
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
402
+ getAbility: mockGetAbility,
403
+ } );
404
+
405
+ const mockResponse = 'Item deleted successfully.';
406
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
407
+ mockResponse
408
+ );
409
+
410
+ const input = { id: '123', format: 'json' };
411
+ const result = await executeAbility( 'test/destructive', input );
412
+
413
+ expect( apiFetch ).toHaveBeenCalledWith( {
414
+ path: '/wp-abilities/v1/abilities/test/destructive/run?input%5Bid%5D=123&input%5Bformat%5D=json',
415
+ method: 'DELETE',
416
+ } );
417
+ expect( result ).toEqual( mockResponse );
418
+ } );
419
+
420
+ it( 'should handle errors in client ability execution', async () => {
421
+ const consoleErrorSpy = jest
422
+ .spyOn( console, 'error' )
423
+ .mockImplementation();
424
+ const executionError = new Error( 'Execution failed' );
425
+ const mockCallback = jest.fn().mockRejectedValue( executionError );
426
+
427
+ const mockAbility: Ability = {
428
+ name: 'test/client-ability',
429
+ label: 'Client Ability',
430
+ description: 'Test client ability',
431
+ category: 'test-category',
432
+ input_schema: { type: 'object' },
433
+ output_schema: { type: 'object' },
434
+ callback: mockCallback,
435
+ };
436
+
437
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
438
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
439
+ getAbility: mockGetAbility,
440
+ } );
441
+
442
+ await expect(
443
+ executeAbility( 'test/client-ability', {} )
444
+ ).rejects.toThrow( 'Execution failed' );
445
+
446
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
447
+ 'Error executing client ability test/client-ability:',
448
+ executionError
449
+ );
450
+
451
+ consoleErrorSpy.mockRestore();
452
+ } );
453
+
454
+ it( 'should handle errors in server ability execution', async () => {
455
+ const consoleErrorSpy = jest
456
+ .spyOn( console, 'error' )
457
+ .mockImplementation();
458
+ const apiError = new Error( 'API request failed' );
459
+
460
+ const mockAbility: Ability = {
461
+ name: 'test/server-ability',
462
+ label: 'Server Ability',
463
+ description: 'Test server ability',
464
+ category: 'test-category',
465
+ input_schema: { type: 'object' },
466
+ output_schema: { type: 'object' },
467
+ };
468
+
469
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
470
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
471
+ getAbility: mockGetAbility,
472
+ } );
473
+
474
+ ( apiFetch as unknown as jest.Mock ).mockRejectedValue( apiError );
475
+
476
+ await expect(
477
+ executeAbility( 'test/server-ability', {} )
478
+ ).rejects.toThrow( 'API request failed' );
479
+
480
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
481
+ 'Error executing ability test/server-ability:',
482
+ apiError
483
+ );
484
+
485
+ consoleErrorSpy.mockRestore();
486
+ } );
487
+
488
+ it( 'should execute ability without callback as server ability', async () => {
489
+ const mockAbility: Ability = {
490
+ name: 'test/ability',
491
+ label: 'Test Ability',
492
+ description: 'Test ability without callback',
493
+ category: 'test-category',
494
+ input_schema: { type: 'object' },
495
+ output_schema: { type: 'object' },
496
+ // No callback - should execute as server ability
497
+ };
498
+
499
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
500
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
501
+ getAbility: mockGetAbility,
502
+ } );
503
+
504
+ const mockResponse = { success: true };
505
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
506
+ mockResponse
507
+ );
508
+
509
+ const result = await executeAbility( 'test/ability', {
510
+ data: 'test',
511
+ } );
512
+
513
+ expect( apiFetch ).toHaveBeenCalledWith( {
514
+ path: '/wp-abilities/v1/abilities/test/ability/run',
515
+ method: 'POST',
516
+ data: { input: { data: 'test' } },
517
+ } );
518
+ expect( result ).toEqual( mockResponse );
519
+ } );
520
+
521
+ it( 'should validate output for client abilities', async () => {
522
+ const mockCallback = jest
523
+ .fn()
524
+ .mockResolvedValue( { invalid: 'response' } );
525
+ const mockAbility: Ability = {
526
+ name: 'test/client-ability',
527
+ label: 'Client Ability',
528
+ description: 'Test client ability',
529
+ category: 'test-category',
530
+ input_schema: { type: 'object' },
531
+ output_schema: {
532
+ type: 'object',
533
+ properties: {
534
+ result: { type: 'string' },
535
+ },
536
+ required: [ 'result' ],
537
+ },
538
+ callback: mockCallback,
539
+ };
540
+
541
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
542
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
543
+ getAbility: mockGetAbility,
544
+ } );
545
+
546
+ await expect(
547
+ executeAbility( 'test/client-ability', {} )
548
+ ).rejects.toThrow( 'invalid output' );
549
+ } );
550
+ } );
551
+
552
+ describe( 'getAbilityCategories', () => {
553
+ it( 'should resolve and return all categories from the store', async () => {
554
+ const mockCategories: AbilityCategory[] = [
555
+ {
556
+ slug: 'data-retrieval',
557
+ label: 'Data Retrieval',
558
+ description: 'Abilities that retrieve data',
559
+ },
560
+ {
561
+ slug: 'user-management',
562
+ label: 'User Management',
563
+ description: 'Abilities for managing users',
564
+ },
565
+ ];
566
+
567
+ const mockGetAbilityCategories = jest
568
+ .fn()
569
+ .mockResolvedValue( mockCategories );
570
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
571
+ getAbilityCategories: mockGetAbilityCategories,
572
+ } );
573
+
574
+ const result = await getAbilityCategories();
575
+
576
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
577
+ expect( mockGetAbilityCategories ).toHaveBeenCalled();
578
+ expect( result ).toEqual( mockCategories );
579
+ } );
580
+
581
+ it( 'should return empty array when no categories exist', async () => {
582
+ const mockGetAbilityCategories = jest.fn().mockResolvedValue( [] );
583
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
584
+ getAbilityCategories: mockGetAbilityCategories,
585
+ } );
586
+
587
+ const result = await getAbilityCategories();
588
+
589
+ expect( result ).toEqual( [] );
590
+ } );
591
+ } );
592
+
593
+ describe( 'getAbilityCategory', () => {
594
+ it( 'should return a specific category by slug', async () => {
595
+ const mockCategory: AbilityCategory = {
596
+ slug: 'data-retrieval',
597
+ label: 'Data Retrieval',
598
+ description: 'Abilities that retrieve data',
599
+ };
600
+
601
+ const mockGetAbilityCategory = jest
602
+ .fn()
603
+ .mockResolvedValue( mockCategory );
604
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
605
+ getAbilityCategory: mockGetAbilityCategory,
606
+ } );
607
+
608
+ const result = await getAbilityCategory( 'data-retrieval' );
609
+
610
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
611
+ expect( mockGetAbilityCategory ).toHaveBeenCalledWith(
612
+ 'data-retrieval'
613
+ );
614
+ expect( result ).toEqual( mockCategory );
615
+ } );
616
+
617
+ it( 'should return null if category not found', async () => {
618
+ const mockGetAbilityCategory = jest.fn().mockResolvedValue( null );
619
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
620
+ getAbilityCategory: mockGetAbilityCategory,
621
+ } );
622
+
623
+ const result = await getAbilityCategory( 'non-existent' );
624
+
625
+ expect( mockGetAbilityCategory ).toHaveBeenCalledWith(
626
+ 'non-existent'
627
+ );
628
+ expect( result ).toBeNull();
629
+ } );
630
+
631
+ it( 'should handle categories with meta', async () => {
632
+ const mockCategory: AbilityCategory = {
633
+ slug: 'user-management',
634
+ label: 'User Management',
635
+ description: 'Abilities for managing users',
636
+ meta: {
637
+ priority: 'high',
638
+ },
639
+ };
640
+
641
+ const mockGetAbilityCategory = jest
642
+ .fn()
643
+ .mockResolvedValue( mockCategory );
644
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
645
+ getAbilityCategory: mockGetAbilityCategory,
646
+ } );
647
+
648
+ const result = await getAbilityCategory( 'user-management' );
649
+
650
+ expect( result ).toEqual( mockCategory );
651
+ expect( result?.meta ).toBeDefined();
652
+ expect( result?.meta?.priority ).toBe( 'high' );
653
+ } );
654
+ } );
655
+ } );