@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,950 @@
1
+ /**
2
+ * Tests for store actions.
3
+ */
4
+
5
+ /**
6
+ * WordPress dependencies
7
+ */
8
+ import { resolveSelect } from '@wordpress/data';
9
+
10
+ /**
11
+ * Internal dependencies
12
+ */
13
+ import {
14
+ receiveAbilities,
15
+ registerAbility,
16
+ unregisterAbility,
17
+ receiveCategories,
18
+ registerAbilityCategory,
19
+ unregisterAbilityCategory,
20
+ } from '../actions';
21
+ import {
22
+ RECEIVE_ABILITIES,
23
+ REGISTER_ABILITY,
24
+ UNREGISTER_ABILITY,
25
+ RECEIVE_CATEGORIES,
26
+ REGISTER_ABILITY_CATEGORY,
27
+ UNREGISTER_ABILITY_CATEGORY,
28
+ } from '../constants';
29
+ import type {
30
+ Ability,
31
+ AbilityCategory,
32
+ AbilityCategoryArgs,
33
+ } from '../../types';
34
+
35
+ // Mock the WordPress data store
36
+ jest.mock( '@wordpress/data', () => ( {
37
+ resolveSelect: jest.fn(),
38
+ } ) );
39
+
40
+ describe( 'Store Actions', () => {
41
+ describe( 'receiveAbilities', () => {
42
+ it( 'should create an action to receive abilities', () => {
43
+ const abilities: Ability[] = [
44
+ {
45
+ name: 'test/ability1',
46
+ label: 'Test Ability 1',
47
+ description: 'First test ability',
48
+ category: 'test-category',
49
+ input_schema: { type: 'object' },
50
+ output_schema: { type: 'object' },
51
+ },
52
+ {
53
+ name: 'test/ability2',
54
+ label: 'Test Ability 2',
55
+ description: 'Second test ability',
56
+ category: 'test-category',
57
+ input_schema: { type: 'object' },
58
+ output_schema: { type: 'object' },
59
+ },
60
+ ];
61
+
62
+ const action = receiveAbilities( abilities );
63
+
64
+ expect( action ).toEqual( {
65
+ type: RECEIVE_ABILITIES,
66
+ abilities,
67
+ } );
68
+ } );
69
+
70
+ it( 'should handle empty abilities array', () => {
71
+ const abilities: Ability[] = [];
72
+ const action = receiveAbilities( abilities );
73
+
74
+ expect( action ).toEqual( {
75
+ type: RECEIVE_ABILITIES,
76
+ abilities: [],
77
+ } );
78
+ } );
79
+ } );
80
+
81
+ describe( 'registerAbility', () => {
82
+ let mockSelect: any;
83
+ let mockDispatch: jest.Mock;
84
+
85
+ beforeEach( () => {
86
+ jest.clearAllMocks();
87
+ const defaultCategories = [
88
+ {
89
+ slug: 'test-category',
90
+ label: 'Test Category',
91
+ description: 'Test category for testing',
92
+ },
93
+ {
94
+ slug: 'data-retrieval',
95
+ label: 'Data Retrieval',
96
+ description: 'Abilities that retrieve data',
97
+ },
98
+ ];
99
+
100
+ mockSelect = {
101
+ getAbility: jest.fn().mockReturnValue( null ),
102
+ getAbilityCategories: jest
103
+ .fn()
104
+ .mockReturnValue( defaultCategories ),
105
+ getAbilityCategory: jest.fn().mockImplementation( ( slug ) => {
106
+ const categories: Record< string, any > = {
107
+ 'test-category': {
108
+ slug: 'test-category',
109
+ label: 'Test Category',
110
+ description: 'Test category for testing',
111
+ },
112
+ 'data-retrieval': {
113
+ slug: 'data-retrieval',
114
+ label: 'Data Retrieval',
115
+ description: 'Abilities that retrieve data',
116
+ },
117
+ };
118
+ return categories[ slug ] || null;
119
+ } ),
120
+ };
121
+ mockDispatch = jest.fn();
122
+
123
+ // Mock resolveSelect to return categories
124
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
125
+ getAbilityCategories: jest
126
+ .fn()
127
+ .mockResolvedValue( defaultCategories ),
128
+ } );
129
+ } );
130
+
131
+ it( 'should register a valid client ability', async () => {
132
+ const ability: Ability = {
133
+ name: 'test/ability',
134
+ label: 'Test Ability',
135
+ description: 'Test ability description',
136
+ category: 'test-category',
137
+ input_schema: {
138
+ type: 'object',
139
+ properties: {
140
+ message: { type: 'string' },
141
+ },
142
+ },
143
+ output_schema: {
144
+ type: 'object',
145
+ properties: {
146
+ success: { type: 'boolean' },
147
+ },
148
+ },
149
+ callback: jest.fn(),
150
+ };
151
+
152
+ const action = registerAbility( ability );
153
+ await action( { select: mockSelect, dispatch: mockDispatch } );
154
+
155
+ expect( mockDispatch ).toHaveBeenCalledWith( {
156
+ type: REGISTER_ABILITY,
157
+ ability,
158
+ } );
159
+ } );
160
+
161
+ it( 'should register server-side abilities', async () => {
162
+ const ability: Ability = {
163
+ name: 'test/server-ability',
164
+ label: 'Server Ability',
165
+ description: 'Server-side ability',
166
+ category: 'test-category',
167
+ input_schema: { type: 'object' },
168
+ output_schema: { type: 'object' },
169
+ };
170
+
171
+ const action = registerAbility( ability );
172
+ await action( { select: mockSelect, dispatch: mockDispatch } );
173
+
174
+ expect( mockDispatch ).toHaveBeenCalledWith( {
175
+ type: REGISTER_ABILITY,
176
+ ability,
177
+ } );
178
+ } );
179
+
180
+ it( 'should validate and reject ability without name', async () => {
181
+ const ability: Ability = {
182
+ name: '',
183
+ label: 'Test Ability',
184
+ description: 'Test description',
185
+ category: 'test-category',
186
+ callback: jest.fn(),
187
+ };
188
+
189
+ const action = registerAbility( ability );
190
+
191
+ await expect(
192
+ action( { select: mockSelect, dispatch: mockDispatch } )
193
+ ).rejects.toThrow( 'Ability name is required' );
194
+ expect( mockDispatch ).not.toHaveBeenCalled();
195
+ } );
196
+
197
+ it( 'should validate and reject ability with invalid name format', async () => {
198
+ const testCases = [
199
+ 'invalid', // No namespace
200
+ 'my-plugin/feature/action', // Multiple slashes
201
+ 'My-Plugin/feature', // Uppercase letters
202
+ 'my_plugin/feature', // Underscores not allowed
203
+ 'my-plugin/feature!', // Special characters not allowed
204
+ 'my plugin/feature', // Spaces not allowed
205
+ ];
206
+
207
+ for ( const invalidName of testCases ) {
208
+ const ability: Ability = {
209
+ name: invalidName,
210
+ label: 'Test Ability',
211
+ description: 'Test description',
212
+ category: 'test-category',
213
+ callback: jest.fn(),
214
+ };
215
+
216
+ const action = registerAbility( ability );
217
+
218
+ await expect(
219
+ action( { select: mockSelect, dispatch: mockDispatch } )
220
+ ).rejects.toThrow(
221
+ 'Ability name must be a string containing a namespace prefix'
222
+ );
223
+ expect( mockDispatch ).not.toHaveBeenCalled();
224
+ mockDispatch.mockClear();
225
+ }
226
+ } );
227
+
228
+ it( 'should validate and reject ability without label', async () => {
229
+ const ability: Ability = {
230
+ name: 'test/ability',
231
+ label: '',
232
+ description: 'Test description',
233
+ category: 'test-category',
234
+ callback: jest.fn(),
235
+ };
236
+
237
+ const action = registerAbility( ability );
238
+
239
+ await expect(
240
+ action( { select: mockSelect, dispatch: mockDispatch } )
241
+ ).rejects.toThrow( 'Ability "test/ability" must have a label' );
242
+ expect( mockDispatch ).not.toHaveBeenCalled();
243
+ } );
244
+
245
+ it( 'should validate and reject ability without description', async () => {
246
+ const ability: Ability = {
247
+ name: 'test/ability',
248
+ label: 'Test Ability',
249
+ description: '',
250
+ category: 'test-category',
251
+ callback: jest.fn(),
252
+ };
253
+
254
+ const action = registerAbility( ability );
255
+
256
+ await expect(
257
+ action( { select: mockSelect, dispatch: mockDispatch } )
258
+ ).rejects.toThrow(
259
+ 'Ability "test/ability" must have a description'
260
+ );
261
+ expect( mockDispatch ).not.toHaveBeenCalled();
262
+ } );
263
+
264
+ it( 'should validate and reject ability without category', async () => {
265
+ const ability: Ability = {
266
+ name: 'test/ability',
267
+ label: 'Test Ability',
268
+ description: 'Test description',
269
+ category: '',
270
+ callback: jest.fn(),
271
+ };
272
+
273
+ const action = registerAbility( ability );
274
+
275
+ await expect(
276
+ action( { select: mockSelect, dispatch: mockDispatch } )
277
+ ).rejects.toThrow( 'Ability "test/ability" must have a category' );
278
+ expect( mockDispatch ).not.toHaveBeenCalled();
279
+ } );
280
+
281
+ it( 'should validate and reject ability with invalid category format', async () => {
282
+ const testCases = [
283
+ 'Data-Retrieval', // Uppercase letters
284
+ 'data_retrieval', // Underscores not allowed
285
+ 'data.retrieval', // Dots not allowed
286
+ 'data/retrieval', // Slashes not allowed
287
+ '-data-retrieval', // Leading dash
288
+ 'data-retrieval-', // Trailing dash
289
+ 'data--retrieval', // Double dash
290
+ ];
291
+
292
+ for ( const invalidCategory of testCases ) {
293
+ const ability: Ability = {
294
+ name: 'test/ability',
295
+ label: 'Test Ability',
296
+ description: 'Test description',
297
+ category: invalidCategory,
298
+ callback: jest.fn(),
299
+ };
300
+
301
+ const action = registerAbility( ability );
302
+
303
+ await expect(
304
+ action( { select: mockSelect, dispatch: mockDispatch } )
305
+ ).rejects.toThrow(
306
+ 'Ability "test/ability" has an invalid category. Category must be lowercase alphanumeric with dashes only'
307
+ );
308
+ expect( mockDispatch ).not.toHaveBeenCalled();
309
+ mockDispatch.mockClear();
310
+ }
311
+ } );
312
+
313
+ it( 'should accept ability with valid category format', async () => {
314
+ const validCategories = [
315
+ 'data-retrieval',
316
+ 'user-management',
317
+ 'analytics-123',
318
+ 'ecommerce',
319
+ ];
320
+
321
+ for ( const validCategory of validCategories ) {
322
+ const ability: Ability = {
323
+ name: 'test/ability-' + validCategory,
324
+ label: 'Test Ability',
325
+ description: 'Test description',
326
+ category: validCategory,
327
+ callback: jest.fn(),
328
+ };
329
+
330
+ const categoriesForTest = [
331
+ {
332
+ slug: 'test-category',
333
+ label: 'Test Category',
334
+ description: 'Test category for testing',
335
+ },
336
+ {
337
+ slug: 'data-retrieval',
338
+ label: 'Data Retrieval',
339
+ description: 'Abilities that retrieve data',
340
+ },
341
+ {
342
+ slug: validCategory,
343
+ label: 'Test Category',
344
+ description: 'Test',
345
+ },
346
+ ];
347
+
348
+ // Mock both select and resolveSelect
349
+ mockSelect.getAbilityCategories.mockReturnValue(
350
+ categoriesForTest
351
+ );
352
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
353
+ getAbilityCategories: jest
354
+ .fn()
355
+ .mockResolvedValue( categoriesForTest ),
356
+ } );
357
+
358
+ mockSelect.getAbility.mockReturnValue( null );
359
+ mockDispatch.mockClear();
360
+
361
+ const action = registerAbility( ability );
362
+ await action( { select: mockSelect, dispatch: mockDispatch } );
363
+
364
+ expect( mockDispatch ).toHaveBeenCalledWith( {
365
+ type: REGISTER_ABILITY,
366
+ ability,
367
+ } );
368
+ }
369
+ } );
370
+
371
+ it( 'should validate and reject ability with non-existent category', async () => {
372
+ mockSelect.getAbilityCategories.mockReturnValue( [
373
+ {
374
+ slug: 'test-category',
375
+ label: 'Test Category',
376
+ description: 'Test category for testing',
377
+ },
378
+ {
379
+ slug: 'data-retrieval',
380
+ label: 'Data Retrieval',
381
+ description: 'Abilities that retrieve data',
382
+ },
383
+ ] );
384
+
385
+ const ability: Ability = {
386
+ name: 'test/ability',
387
+ label: 'Test Ability',
388
+ description: 'Test description',
389
+ category: 'non-existent-category',
390
+ callback: jest.fn(),
391
+ };
392
+
393
+ const action = registerAbility( ability );
394
+
395
+ await expect(
396
+ action( { select: mockSelect, dispatch: mockDispatch } )
397
+ ).rejects.toThrow(
398
+ 'Ability "test/ability" references non-existent category "non-existent-category". Please register the category first.'
399
+ );
400
+ expect( mockDispatch ).not.toHaveBeenCalled();
401
+ } );
402
+
403
+ it( 'should accept ability with existing category', async () => {
404
+ const categoriesForTest = [
405
+ {
406
+ slug: 'test-category',
407
+ label: 'Test Category',
408
+ description: 'Test category for testing',
409
+ },
410
+ {
411
+ slug: 'data-retrieval',
412
+ label: 'Data Retrieval',
413
+ description: 'Abilities that retrieve data',
414
+ },
415
+ ];
416
+
417
+ mockSelect.getAbilityCategories.mockReturnValue(
418
+ categoriesForTest
419
+ );
420
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
421
+ getAbilityCategories: jest
422
+ .fn()
423
+ .mockResolvedValue( categoriesForTest ),
424
+ } );
425
+
426
+ const ability: Ability = {
427
+ name: 'test/ability',
428
+ label: 'Test Ability',
429
+ description: 'Test description',
430
+ category: 'data-retrieval',
431
+ callback: jest.fn(),
432
+ };
433
+
434
+ const action = registerAbility( ability );
435
+ await action( { select: mockSelect, dispatch: mockDispatch } );
436
+
437
+ // resolveSelect should have been called to load categories
438
+ expect( resolveSelect ).toHaveBeenCalledWith( 'core/abilities' );
439
+ expect( mockDispatch ).toHaveBeenCalledWith( {
440
+ type: REGISTER_ABILITY,
441
+ ability,
442
+ } );
443
+ } );
444
+
445
+ it( 'should validate and reject ability with invalid callback', async () => {
446
+ const ability: Ability = {
447
+ name: 'test/ability',
448
+ label: 'Test Ability',
449
+ description: 'Test description',
450
+ category: 'test-category',
451
+ callback: 'not a function' as any,
452
+ };
453
+
454
+ const action = registerAbility( ability );
455
+
456
+ await expect(
457
+ action( { select: mockSelect, dispatch: mockDispatch } )
458
+ ).rejects.toThrow(
459
+ 'Ability "test/ability" has an invalid callback. Callback must be a function'
460
+ );
461
+ expect( mockDispatch ).not.toHaveBeenCalled();
462
+ } );
463
+
464
+ it( 'should validate and reject already registered ability', async () => {
465
+ const existingAbility: Ability = {
466
+ name: 'test/ability',
467
+ label: 'Existing Ability',
468
+ description: 'Already registered',
469
+ category: 'test-category',
470
+ };
471
+
472
+ mockSelect.getAbility.mockReturnValue( existingAbility );
473
+
474
+ const ability: Ability = {
475
+ name: 'test/ability',
476
+ label: 'Test Ability',
477
+ description: 'Test description',
478
+ category: 'test-category',
479
+ callback: jest.fn(),
480
+ };
481
+
482
+ const action = registerAbility( ability );
483
+
484
+ await expect(
485
+ action( { select: mockSelect, dispatch: mockDispatch } )
486
+ ).rejects.toThrow( 'Ability "test/ability" is already registered' );
487
+ expect( mockDispatch ).not.toHaveBeenCalled();
488
+ } );
489
+
490
+ it( 'should load categories using resolveSelect before validation', async () => {
491
+ const categories = [
492
+ {
493
+ slug: 'test-category',
494
+ label: 'Test Category',
495
+ description: 'Test category',
496
+ },
497
+ ];
498
+
499
+ // Mock resolveSelect to return categories directly
500
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
501
+ getAbilityCategories: jest.fn().mockResolvedValue( categories ),
502
+ } );
503
+
504
+ const ability: Ability = {
505
+ name: 'test/ability',
506
+ label: 'Test Ability',
507
+ description: 'Test description',
508
+ category: 'test-category',
509
+ callback: jest.fn(),
510
+ };
511
+
512
+ const action = registerAbility( ability );
513
+ await action( { select: mockSelect, dispatch: mockDispatch } );
514
+
515
+ // Should have called resolveSelect to load categories
516
+ expect( resolveSelect ).toHaveBeenCalledWith( 'core/abilities' );
517
+ // Should have successfully registered
518
+ expect( mockDispatch ).toHaveBeenCalledWith( {
519
+ type: REGISTER_ABILITY,
520
+ ability,
521
+ } );
522
+ } );
523
+ } );
524
+
525
+ describe( 'unregisterAbility', () => {
526
+ it( 'should create an action to unregister an ability', () => {
527
+ const abilityName = 'test/ability';
528
+ const action = unregisterAbility( abilityName );
529
+
530
+ expect( action ).toEqual( {
531
+ type: UNREGISTER_ABILITY,
532
+ name: abilityName,
533
+ } );
534
+ } );
535
+
536
+ it( 'should handle valid namespaced ability names', () => {
537
+ const abilityName = 'my-plugin/feature-action';
538
+ const action = unregisterAbility( abilityName );
539
+
540
+ expect( action ).toEqual( {
541
+ type: UNREGISTER_ABILITY,
542
+ name: abilityName,
543
+ } );
544
+ } );
545
+ } );
546
+
547
+ describe( 'receiveCategories', () => {
548
+ it( 'should create an action to receive categories', () => {
549
+ const categories: AbilityCategory[] = [
550
+ {
551
+ slug: 'data-retrieval',
552
+ label: 'Data Retrieval',
553
+ description: 'Abilities that retrieve data',
554
+ },
555
+ {
556
+ slug: 'user-management',
557
+ label: 'User Management',
558
+ description: 'Abilities for managing users',
559
+ },
560
+ ];
561
+
562
+ const action = receiveCategories( categories );
563
+
564
+ expect( action ).toEqual( {
565
+ type: RECEIVE_CATEGORIES,
566
+ categories,
567
+ } );
568
+ } );
569
+
570
+ it( 'should handle empty categories array', () => {
571
+ const categories: AbilityCategory[] = [];
572
+ const action = receiveCategories( categories );
573
+
574
+ expect( action ).toEqual( {
575
+ type: RECEIVE_CATEGORIES,
576
+ categories: [],
577
+ } );
578
+ } );
579
+ } );
580
+
581
+ describe( 'registerAbilityCategory', () => {
582
+ let mockSelect: any;
583
+ let mockDispatch: jest.Mock;
584
+
585
+ beforeEach( () => {
586
+ jest.clearAllMocks();
587
+ mockSelect = {
588
+ getAbilityCategory: jest.fn().mockReturnValue( null ),
589
+ getAbilityCategories: jest.fn().mockReturnValue( [] ),
590
+ };
591
+ mockDispatch = jest.fn();
592
+
593
+ // Mock resolveSelect to return a mock that resolves the getAbilityCategories call
594
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
595
+ getAbilityCategories: jest.fn().mockResolvedValue( undefined ),
596
+ } );
597
+ } );
598
+
599
+ it( 'should register a valid category', async () => {
600
+ const slug = 'test-category';
601
+ const args: AbilityCategoryArgs = {
602
+ label: 'Test Category',
603
+ description: 'A test category for testing',
604
+ };
605
+
606
+ const action = registerAbilityCategory( slug, args );
607
+ await action( { select: mockSelect, dispatch: mockDispatch } );
608
+
609
+ expect( mockDispatch ).toHaveBeenCalledWith( {
610
+ type: REGISTER_ABILITY_CATEGORY,
611
+ category: {
612
+ slug,
613
+ label: args.label,
614
+ description: args.description,
615
+ meta: {
616
+ _clientRegistered: true,
617
+ },
618
+ },
619
+ } );
620
+ } );
621
+
622
+ it( 'should register a category with meta', async () => {
623
+ const slug = 'test-category';
624
+ const args: AbilityCategoryArgs = {
625
+ label: 'Test Category',
626
+ description: 'A test category',
627
+ meta: { foo: 'bar', nested: { key: 'value' } },
628
+ };
629
+
630
+ const action = registerAbilityCategory( slug, args );
631
+ await action( { select: mockSelect, dispatch: mockDispatch } );
632
+
633
+ expect( mockDispatch ).toHaveBeenCalledWith( {
634
+ type: REGISTER_ABILITY_CATEGORY,
635
+ category: {
636
+ slug,
637
+ label: args.label,
638
+ description: args.description,
639
+ meta: {
640
+ ...args.meta,
641
+ _clientRegistered: true,
642
+ },
643
+ },
644
+ } );
645
+ } );
646
+
647
+ it( 'should validate and reject empty slug', async () => {
648
+ const args: AbilityCategoryArgs = {
649
+ label: 'Test',
650
+ description: 'Test',
651
+ };
652
+
653
+ const action = registerAbilityCategory( '', args );
654
+
655
+ await expect(
656
+ action( { select: mockSelect, dispatch: mockDispatch } )
657
+ ).rejects.toThrow( 'Category slug is required' );
658
+ expect( mockDispatch ).not.toHaveBeenCalled();
659
+ } );
660
+
661
+ it( 'should validate and reject invalid slug formats', async () => {
662
+ const testCases = [
663
+ 'Data-Retrieval', // Uppercase
664
+ 'data_retrieval', // Underscores
665
+ 'data.retrieval', // Dots
666
+ 'data/retrieval', // Slashes
667
+ '-data-retrieval', // Leading dash
668
+ 'data-retrieval-', // Trailing dash
669
+ 'data--retrieval', // Double dash
670
+ 'data retrieval', // Spaces
671
+ 'data!retrieval', // Special characters
672
+ ];
673
+
674
+ const args: AbilityCategoryArgs = {
675
+ label: 'Test',
676
+ description: 'Test',
677
+ };
678
+
679
+ for ( const invalidSlug of testCases ) {
680
+ const action = registerAbilityCategory( invalidSlug, args );
681
+
682
+ await expect(
683
+ action( { select: mockSelect, dispatch: mockDispatch } )
684
+ ).rejects.toThrow(
685
+ 'Category slug must contain only lowercase alphanumeric characters and dashes'
686
+ );
687
+ expect( mockDispatch ).not.toHaveBeenCalled();
688
+ mockDispatch.mockClear();
689
+ }
690
+ } );
691
+
692
+ it( 'should accept valid slug formats', async () => {
693
+ const validSlugs = [
694
+ 'data-retrieval',
695
+ 'user-management',
696
+ 'analytics-123',
697
+ 'ecommerce',
698
+ 'a',
699
+ '123',
700
+ 'test-multiple-words-with-dashes',
701
+ ];
702
+
703
+ const args: AbilityCategoryArgs = {
704
+ label: 'Test Category',
705
+ description: 'Test description',
706
+ };
707
+
708
+ for ( const validSlug of validSlugs ) {
709
+ mockSelect.getAbilityCategory.mockReturnValue( null );
710
+ mockDispatch.mockClear();
711
+
712
+ const action = registerAbilityCategory( validSlug, args );
713
+ await action( { select: mockSelect, dispatch: mockDispatch } );
714
+
715
+ expect( mockDispatch ).toHaveBeenCalledWith( {
716
+ type: REGISTER_ABILITY_CATEGORY,
717
+ category: {
718
+ slug: validSlug,
719
+ label: args.label,
720
+ description: args.description,
721
+ meta: {
722
+ _clientRegistered: true,
723
+ },
724
+ },
725
+ } );
726
+ }
727
+ } );
728
+
729
+ it( 'should validate and reject missing label', async () => {
730
+ const slug = 'test-category';
731
+ const args = {
732
+ label: '',
733
+ description: 'Test',
734
+ } as AbilityCategoryArgs;
735
+
736
+ const action = registerAbilityCategory( slug, args );
737
+
738
+ await expect(
739
+ action( { select: mockSelect, dispatch: mockDispatch } )
740
+ ).rejects.toThrow(
741
+ 'The category properties must contain a `label` string.'
742
+ );
743
+ expect( mockDispatch ).not.toHaveBeenCalled();
744
+ } );
745
+
746
+ it( 'should validate and reject non-string label', async () => {
747
+ const slug = 'test-category';
748
+ const args = {
749
+ label: 123 as any,
750
+ description: 'Test',
751
+ };
752
+
753
+ const action = registerAbilityCategory( slug, args );
754
+
755
+ await expect(
756
+ action( { select: mockSelect, dispatch: mockDispatch } )
757
+ ).rejects.toThrow(
758
+ 'The category properties must contain a `label` string.'
759
+ );
760
+ expect( mockDispatch ).not.toHaveBeenCalled();
761
+ } );
762
+
763
+ it( 'should validate and reject missing description', async () => {
764
+ const slug = 'test-category';
765
+ const args = {
766
+ label: 'Test',
767
+ description: '',
768
+ } as AbilityCategoryArgs;
769
+
770
+ const action = registerAbilityCategory( slug, args );
771
+
772
+ await expect(
773
+ action( { select: mockSelect, dispatch: mockDispatch } )
774
+ ).rejects.toThrow(
775
+ 'The category properties must contain a `description` string.'
776
+ );
777
+ expect( mockDispatch ).not.toHaveBeenCalled();
778
+ } );
779
+
780
+ it( 'should validate and reject non-string description', async () => {
781
+ const slug = 'test-category';
782
+ const args = {
783
+ label: 'Test',
784
+ description: 123 as any,
785
+ };
786
+
787
+ const action = registerAbilityCategory( slug, args );
788
+
789
+ await expect(
790
+ action( { select: mockSelect, dispatch: mockDispatch } )
791
+ ).rejects.toThrow(
792
+ 'The category properties must contain a `description` string.'
793
+ );
794
+ expect( mockDispatch ).not.toHaveBeenCalled();
795
+ } );
796
+
797
+ it( 'should validate and reject non-object meta', async () => {
798
+ const slug = 'test-category';
799
+ const args = {
800
+ label: 'Test',
801
+ description: 'Test',
802
+ meta: 'invalid' as any,
803
+ };
804
+
805
+ const action = registerAbilityCategory( slug, args );
806
+
807
+ await expect(
808
+ action( { select: mockSelect, dispatch: mockDispatch } )
809
+ ).rejects.toThrow(
810
+ 'The category properties should provide a valid `meta` object.'
811
+ );
812
+ expect( mockDispatch ).not.toHaveBeenCalled();
813
+ } );
814
+
815
+ it( 'should validate and reject array as meta', async () => {
816
+ const slug = 'test-category';
817
+ const args = {
818
+ label: 'Test',
819
+ description: 'Test',
820
+ meta: [ 'invalid' ] as any,
821
+ };
822
+
823
+ const action = registerAbilityCategory( slug, args );
824
+
825
+ await expect(
826
+ action( { select: mockSelect, dispatch: mockDispatch } )
827
+ ).rejects.toThrow(
828
+ 'The category properties should provide a valid `meta` object.'
829
+ );
830
+ expect( mockDispatch ).not.toHaveBeenCalled();
831
+ } );
832
+
833
+ it( 'should validate and reject already registered category', async () => {
834
+ const existingCategory: AbilityCategory = {
835
+ slug: 'test-category',
836
+ label: 'Existing Category',
837
+ description: 'Already registered',
838
+ };
839
+
840
+ mockSelect.getAbilityCategory.mockReturnValue( existingCategory );
841
+
842
+ const args: AbilityCategoryArgs = {
843
+ label: 'Test',
844
+ description: 'Test',
845
+ };
846
+
847
+ const action = registerAbilityCategory( 'test-category', args );
848
+
849
+ await expect(
850
+ action( { select: mockSelect, dispatch: mockDispatch } )
851
+ ).rejects.toThrow(
852
+ 'Category "test-category" is already registered.'
853
+ );
854
+ expect( mockDispatch ).not.toHaveBeenCalled();
855
+ } );
856
+
857
+ it( 'should allow registering ability after registering category', async () => {
858
+ // First register a category
859
+ const categorySlug = 'new-category';
860
+ const categoryArgs: AbilityCategoryArgs = {
861
+ label: 'New Category',
862
+ description: 'A newly registered category',
863
+ };
864
+
865
+ const categoryAction = registerAbilityCategory(
866
+ categorySlug,
867
+ categoryArgs
868
+ );
869
+ await categoryAction( {
870
+ select: mockSelect,
871
+ dispatch: mockDispatch,
872
+ } );
873
+
874
+ // Verify category was registered
875
+ expect( mockDispatch ).toHaveBeenCalledWith( {
876
+ type: REGISTER_ABILITY_CATEGORY,
877
+ category: {
878
+ slug: categorySlug,
879
+ label: categoryArgs.label,
880
+ description: categoryArgs.description,
881
+ meta: {
882
+ _clientRegistered: true,
883
+ },
884
+ },
885
+ } );
886
+
887
+ // Now mock that the category exists for ability registration
888
+ const categoriesWithNew = [
889
+ {
890
+ slug: categorySlug,
891
+ label: categoryArgs.label,
892
+ description: categoryArgs.description,
893
+ },
894
+ ];
895
+ mockSelect.getAbilityCategories = jest
896
+ .fn()
897
+ .mockReturnValue( categoriesWithNew );
898
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
899
+ getAbilityCategories: jest
900
+ .fn()
901
+ .mockResolvedValue( categoriesWithNew ),
902
+ } );
903
+ mockSelect.getAbility = jest.fn().mockReturnValue( null );
904
+ mockDispatch.mockClear();
905
+
906
+ // Register an ability using the new category
907
+ const ability: Ability = {
908
+ name: 'test/ability',
909
+ label: 'Test Ability',
910
+ description: 'Test description',
911
+ category: categorySlug,
912
+ callback: jest.fn(),
913
+ };
914
+
915
+ const abilityAction = registerAbility( ability );
916
+ await abilityAction( {
917
+ select: mockSelect,
918
+ dispatch: mockDispatch,
919
+ } );
920
+
921
+ // Should successfully register with the new category
922
+ expect( mockDispatch ).toHaveBeenCalledWith( {
923
+ type: REGISTER_ABILITY,
924
+ ability,
925
+ } );
926
+ } );
927
+ } );
928
+
929
+ describe( 'unregisterAbilityCategory', () => {
930
+ it( 'should create an action to unregister a category', () => {
931
+ const slug = 'test-category';
932
+ const action = unregisterAbilityCategory( slug );
933
+
934
+ expect( action ).toEqual( {
935
+ type: UNREGISTER_ABILITY_CATEGORY,
936
+ slug,
937
+ } );
938
+ } );
939
+
940
+ it( 'should handle valid category slugs', () => {
941
+ const slug = 'data-retrieval';
942
+ const action = unregisterAbilityCategory( slug );
943
+
944
+ expect( action ).toEqual( {
945
+ type: UNREGISTER_ABILITY_CATEGORY,
946
+ slug,
947
+ } );
948
+ } );
949
+ } );
950
+ } );