@jmruthers/pace-core 0.5.139 → 0.5.141

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 (161) hide show
  1. package/README.md +2 -2
  2. package/dist/{DataTable-JXFCA2BJ.js → DataTable-EGIN2NKK.js} +3 -3
  3. package/dist/{EventLogo-rFL_kRjk.d.ts → EventLogo-B3V3otev.d.ts} +307 -1
  4. package/dist/{chunk-BOOI7GK2.js → chunk-3R472UXR.js} +117 -1
  5. package/dist/chunk-3R472UXR.js.map +1 -0
  6. package/dist/{chunk-5JMOHWDI.js → chunk-ALUN6O3G.js} +492 -324
  7. package/dist/chunk-ALUN6O3G.js.map +1 -0
  8. package/dist/{chunk-6DXZ6V5Q.js → chunk-PZV3XZKJ.js} +2 -2
  9. package/dist/{chunk-TLT2ZR3L.js → chunk-WKTQM2IC.js} +2 -2
  10. package/dist/components.d.ts +3 -1
  11. package/dist/components.js +15 -3
  12. package/dist/components.js.map +1 -1
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +18 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/rbac/index.d.ts +94 -1
  17. package/dist/rbac/index.js +4 -2
  18. package/dist/utils.d.ts +1 -1
  19. package/dist/utils.js +17 -5
  20. package/dist/utils.js.map +1 -1
  21. package/docs/api/README.md +2 -2
  22. package/docs/api/classes/ColumnFactory.md +1 -1
  23. package/docs/api/classes/ErrorBoundary.md +1 -1
  24. package/docs/api/classes/InvalidScopeError.md +1 -1
  25. package/docs/api/classes/MissingUserContextError.md +1 -1
  26. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  27. package/docs/api/classes/PermissionDeniedError.md +1 -1
  28. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  29. package/docs/api/classes/RBACAuditManager.md +1 -1
  30. package/docs/api/classes/RBACCache.md +1 -1
  31. package/docs/api/classes/RBACEngine.md +1 -1
  32. package/docs/api/classes/RBACError.md +1 -1
  33. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  34. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  35. package/docs/api/classes/StorageUtils.md +1 -1
  36. package/docs/api/enums/FileCategory.md +1 -1
  37. package/docs/api/interfaces/AggregateConfig.md +1 -1
  38. package/docs/api/interfaces/BadgeProps.md +1 -1
  39. package/docs/api/interfaces/ButtonProps.md +1 -1
  40. package/docs/api/interfaces/CalendarProps.md +40 -0
  41. package/docs/api/interfaces/CardProps.md +1 -1
  42. package/docs/api/interfaces/ColorPalette.md +1 -1
  43. package/docs/api/interfaces/ColorShade.md +1 -1
  44. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  45. package/docs/api/interfaces/DataRecord.md +1 -1
  46. package/docs/api/interfaces/DataTableAction.md +1 -1
  47. package/docs/api/interfaces/DataTableColumn.md +1 -1
  48. package/docs/api/interfaces/DataTableProps.md +1 -1
  49. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  50. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  51. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  52. package/docs/api/interfaces/EventAppRoleData.md +1 -1
  53. package/docs/api/interfaces/EventLogoProps.md +1 -1
  54. package/docs/api/interfaces/ExportColumn.md +1 -1
  55. package/docs/api/interfaces/ExportOptions.md +1 -1
  56. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  57. package/docs/api/interfaces/FileMetadata.md +1 -1
  58. package/docs/api/interfaces/FileReference.md +1 -1
  59. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  60. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  61. package/docs/api/interfaces/FileUploadProps.md +1 -1
  62. package/docs/api/interfaces/FooterProps.md +1 -1
  63. package/docs/api/interfaces/GrantEventAppRoleParams.md +1 -1
  64. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  65. package/docs/api/interfaces/InputProps.md +1 -1
  66. package/docs/api/interfaces/LabelProps.md +1 -1
  67. package/docs/api/interfaces/LoginFormProps.md +1 -1
  68. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  69. package/docs/api/interfaces/NavigationContextType.md +1 -1
  70. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  71. package/docs/api/interfaces/NavigationItem.md +1 -1
  72. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  73. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  74. package/docs/api/interfaces/Organisation.md +1 -1
  75. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  76. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  77. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  78. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  79. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  80. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  81. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  82. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  83. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  84. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  85. package/docs/api/interfaces/PaletteData.md +1 -1
  86. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  87. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  88. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  89. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  90. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  91. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  92. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  93. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  94. package/docs/api/interfaces/RBACConfig.md +1 -1
  95. package/docs/api/interfaces/RBACLogger.md +1 -1
  96. package/docs/api/interfaces/ResourcePermissions.md +155 -0
  97. package/docs/api/interfaces/RevokeEventAppRoleParams.md +1 -1
  98. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  99. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  100. package/docs/api/interfaces/RoleManagementResult.md +1 -1
  101. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  102. package/docs/api/interfaces/RouteConfig.md +1 -1
  103. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  104. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  105. package/docs/api/interfaces/SessionRestorationLoaderProps.md +1 -1
  106. package/docs/api/interfaces/StorageConfig.md +1 -1
  107. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  108. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  109. package/docs/api/interfaces/StorageListOptions.md +1 -1
  110. package/docs/api/interfaces/StorageListResult.md +1 -1
  111. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  112. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  113. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  114. package/docs/api/interfaces/StyleImport.md +1 -1
  115. package/docs/api/interfaces/SwitchProps.md +1 -1
  116. package/docs/api/interfaces/TabsContentProps.md +9 -0
  117. package/docs/api/interfaces/TabsListProps.md +9 -0
  118. package/docs/api/interfaces/TabsProps.md +9 -0
  119. package/docs/api/interfaces/TabsTriggerProps.md +9 -0
  120. package/docs/api/interfaces/TextareaProps.md +53 -0
  121. package/docs/api/interfaces/ToastActionElement.md +1 -1
  122. package/docs/api/interfaces/ToastProps.md +1 -1
  123. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  124. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  125. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  126. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  127. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  128. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  129. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  130. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  131. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  132. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  133. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  134. package/docs/api/interfaces/UseResourcePermissionsOptions.md +34 -0
  135. package/docs/api/interfaces/UserEventAccess.md +1 -1
  136. package/docs/api/interfaces/UserMenuProps.md +1 -1
  137. package/docs/api/interfaces/UserProfile.md +1 -1
  138. package/docs/api/modules.md +289 -2
  139. package/docs/getting-started/examples/basic-auth-app.md +196 -0
  140. package/docs/getting-started/examples/full-featured-app.md +616 -0
  141. package/package.json +3 -1
  142. package/src/components/Calendar/Calendar.test.tsx +338 -0
  143. package/src/components/Calendar/Calendar.tsx +192 -0
  144. package/src/components/Calendar/index.ts +10 -0
  145. package/src/components/Tabs/Tabs.test.tsx +439 -0
  146. package/src/components/Tabs/Tabs.tsx +202 -0
  147. package/src/components/Tabs/index.ts +10 -0
  148. package/src/components/Textarea/Textarea.test.tsx +269 -0
  149. package/src/components/Textarea/Textarea.tsx +133 -0
  150. package/src/components/Textarea/index.ts +10 -0
  151. package/src/components/index.ts +11 -0
  152. package/src/index.ts +11 -0
  153. package/src/rbac/hooks/index.ts +2 -0
  154. package/src/rbac/hooks/useResourcePermissions.test.ts +633 -0
  155. package/src/rbac/hooks/useResourcePermissions.ts +235 -0
  156. package/src/utils/performance/bundleAnalysis.ts +17 -3
  157. package/dist/chunk-5JMOHWDI.js.map +0 -1
  158. package/dist/chunk-BOOI7GK2.js.map +0 -1
  159. /package/dist/{DataTable-JXFCA2BJ.js.map → DataTable-EGIN2NKK.js.map} +0 -0
  160. /package/dist/{chunk-6DXZ6V5Q.js.map → chunk-PZV3XZKJ.js.map} +0 -0
  161. /package/dist/{chunk-TLT2ZR3L.js.map → chunk-WKTQM2IC.js.map} +0 -0
@@ -0,0 +1,633 @@
1
+ /**
2
+ * @file useResourcePermissions Hook Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module RBAC/Hooks
5
+ * @since 1.0.0
6
+ *
7
+ * Comprehensive tests for the useResourcePermissions hook covering all critical functionality.
8
+ */
9
+
10
+ import { renderHook, waitFor } from '@testing-library/react';
11
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
12
+ import { useResourcePermissions } from './useResourcePermissions';
13
+ import type { Scope } from '../types';
14
+
15
+ // Mock dependencies
16
+ vi.mock('../../providers/services/UnifiedAuthProvider', () => ({
17
+ useUnifiedAuth: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('../../hooks/useOrganisations', () => ({
21
+ useOrganisations: vi.fn(),
22
+ }));
23
+
24
+ vi.mock('../../hooks/useEvents', () => ({
25
+ useEvents: vi.fn(),
26
+ }));
27
+
28
+ vi.mock('./useResolvedScope', () => ({
29
+ useResolvedScope: vi.fn(),
30
+ }));
31
+
32
+ vi.mock('./usePermissions', () => ({
33
+ useCan: vi.fn(),
34
+ }));
35
+
36
+ import { useUnifiedAuth } from '../../providers/services/UnifiedAuthProvider';
37
+ import { useOrganisations } from '../../hooks/useOrganisations';
38
+ import { useEvents } from '../../hooks/useEvents';
39
+ import { useResolvedScope } from './useResolvedScope';
40
+ import { useCan } from './usePermissions';
41
+
42
+ describe('useResourcePermissions Hook', () => {
43
+ const mockUseUnifiedAuth = vi.mocked(useUnifiedAuth);
44
+ const mockUseOrganisations = vi.mocked(useOrganisations);
45
+ const mockUseEvents = vi.mocked(useEvents);
46
+ const mockUseResolvedScope = vi.mocked(useResolvedScope);
47
+ const mockUseCan = vi.mocked(useCan);
48
+
49
+ const mockUser = {
50
+ id: 'user-123',
51
+ email: 'test@example.com',
52
+ };
53
+
54
+ const mockSupabase = {
55
+ from: vi.fn(),
56
+ } as any;
57
+
58
+ const mockSelectedOrganisation = {
59
+ id: 'org-123',
60
+ name: 'Test Organisation',
61
+ };
62
+
63
+ const mockSelectedEvent = {
64
+ event_id: 'event-123',
65
+ event_name: 'Test Event',
66
+ };
67
+
68
+ const mockScope: Scope = {
69
+ organisationId: 'org-123',
70
+ eventId: 'event-123',
71
+ appId: 'app-123',
72
+ };
73
+
74
+ beforeEach(() => {
75
+ vi.clearAllMocks();
76
+
77
+ // Default mock implementations
78
+ mockUseUnifiedAuth.mockReturnValue({
79
+ user: mockUser,
80
+ supabase: mockSupabase,
81
+ } as any);
82
+
83
+ mockUseOrganisations.mockReturnValue({
84
+ selectedOrganisation: mockSelectedOrganisation,
85
+ } as any);
86
+
87
+ mockUseEvents.mockReturnValue({
88
+ selectedEvent: mockSelectedEvent,
89
+ } as any);
90
+
91
+ mockUseResolvedScope.mockReturnValue({
92
+ resolvedScope: mockScope,
93
+ isLoading: false,
94
+ error: null,
95
+ });
96
+
97
+ // Default useCan mocks - all permissions allowed
98
+ mockUseCan.mockReturnValue({
99
+ can: true,
100
+ isLoading: false,
101
+ error: null,
102
+ refetch: vi.fn(),
103
+ } as any);
104
+ });
105
+
106
+ afterEach(() => {
107
+ vi.clearAllMocks();
108
+ });
109
+
110
+ describe('Basic Functionality', () => {
111
+ it('returns permission check functions', () => {
112
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
113
+
114
+ expect(result.current.canCreate).toBeTypeOf('function');
115
+ expect(result.current.canUpdate).toBeTypeOf('function');
116
+ expect(result.current.canDelete).toBeTypeOf('function');
117
+ expect(result.current.canRead).toBeTypeOf('function');
118
+ });
119
+
120
+ it('returns scope object', () => {
121
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
122
+
123
+ expect(result.current.scope).toEqual(mockScope);
124
+ });
125
+
126
+ it('calls useResolvedScope with correct parameters', () => {
127
+ renderHook(() => useResourcePermissions('contacts'));
128
+
129
+ expect(mockUseResolvedScope).toHaveBeenCalledWith({
130
+ supabase: mockSupabase,
131
+ selectedOrganisationId: 'org-123',
132
+ selectedEventId: 'event-123',
133
+ });
134
+ });
135
+
136
+ it('calls useCan for each permission type', () => {
137
+ renderHook(() => useResourcePermissions('contacts'));
138
+
139
+ expect(mockUseCan).toHaveBeenCalledTimes(4); // create, update, delete, read
140
+ expect(mockUseCan).toHaveBeenCalledWith(
141
+ 'user-123',
142
+ mockScope,
143
+ 'create:contacts',
144
+ undefined,
145
+ true
146
+ );
147
+ expect(mockUseCan).toHaveBeenCalledWith(
148
+ 'user-123',
149
+ mockScope,
150
+ 'update:contacts',
151
+ undefined,
152
+ true
153
+ );
154
+ expect(mockUseCan).toHaveBeenCalledWith(
155
+ 'user-123',
156
+ mockScope,
157
+ 'delete:contacts',
158
+ undefined,
159
+ true
160
+ );
161
+ expect(mockUseCan).toHaveBeenCalledWith(
162
+ 'user-123',
163
+ mockScope,
164
+ 'read:contacts',
165
+ undefined,
166
+ true
167
+ );
168
+ });
169
+ });
170
+
171
+ describe('Permission Checking', () => {
172
+ it('returns true when user can create resource', () => {
173
+ mockUseCan.mockReturnValue({
174
+ can: true,
175
+ isLoading: false,
176
+ error: null,
177
+ refetch: vi.fn(),
178
+ } as any);
179
+
180
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
181
+
182
+ expect(result.current.canCreate('contacts')).toBe(true);
183
+ });
184
+
185
+ it('returns false when user cannot create resource', () => {
186
+ mockUseCan.mockImplementation((userId, scope, permission) => {
187
+ if (permission === 'create:contacts') {
188
+ return {
189
+ can: false,
190
+ isLoading: false,
191
+ error: null,
192
+ refetch: vi.fn(),
193
+ } as any;
194
+ }
195
+ return {
196
+ can: true,
197
+ isLoading: false,
198
+ error: null,
199
+ refetch: vi.fn(),
200
+ } as any;
201
+ });
202
+
203
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
204
+
205
+ expect(result.current.canCreate('contacts')).toBe(false);
206
+ expect(result.current.canUpdate('contacts')).toBe(true);
207
+ expect(result.current.canDelete('contacts')).toBe(true);
208
+ });
209
+
210
+ it('returns false for different resource name', () => {
211
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
212
+
213
+ expect(result.current.canCreate('risks')).toBe(false);
214
+ expect(result.current.canUpdate('risks')).toBe(false);
215
+ expect(result.current.canDelete('risks')).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe('Read Permissions', () => {
220
+ it('returns true for canRead when enableRead is false (default)', () => {
221
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
222
+
223
+ expect(result.current.canRead('contacts')).toBe(true);
224
+ });
225
+
226
+ it('checks read permissions when enableRead is true', () => {
227
+ mockUseCan.mockImplementation((userId, scope, permission) => {
228
+ if (permission === 'read:contacts') {
229
+ return {
230
+ can: true,
231
+ isLoading: false,
232
+ error: null,
233
+ refetch: vi.fn(),
234
+ } as any;
235
+ }
236
+ return {
237
+ can: false,
238
+ isLoading: false,
239
+ error: null,
240
+ refetch: vi.fn(),
241
+ } as any;
242
+ });
243
+
244
+ const { result } = renderHook(() =>
245
+ useResourcePermissions('contacts', { enableRead: true })
246
+ );
247
+
248
+ expect(result.current.canRead('contacts')).toBe(true);
249
+ });
250
+
251
+ it('returns false for read when permission is denied and enableRead is true', () => {
252
+ mockUseCan.mockImplementation((userId, scope, permission) => {
253
+ if (permission === 'read:contacts') {
254
+ return {
255
+ can: false,
256
+ isLoading: false,
257
+ error: null,
258
+ refetch: vi.fn(),
259
+ } as any;
260
+ }
261
+ return {
262
+ can: true,
263
+ isLoading: false,
264
+ error: null,
265
+ refetch: vi.fn(),
266
+ } as any;
267
+ });
268
+
269
+ const { result } = renderHook(() =>
270
+ useResourcePermissions('contacts', { enableRead: true })
271
+ );
272
+
273
+ expect(result.current.canRead('contacts')).toBe(false);
274
+ });
275
+ });
276
+
277
+ describe('Scope Resolution', () => {
278
+ it('uses resolved scope when available', () => {
279
+ const resolvedScope: Scope = {
280
+ organisationId: 'org-resolved',
281
+ eventId: 'event-resolved',
282
+ appId: 'app-resolved',
283
+ };
284
+
285
+ mockUseResolvedScope.mockReturnValue({
286
+ resolvedScope,
287
+ isLoading: false,
288
+ error: null,
289
+ });
290
+
291
+ renderHook(() => useResourcePermissions('contacts'));
292
+
293
+ expect(mockUseCan).toHaveBeenCalledWith(
294
+ expect.any(String),
295
+ resolvedScope,
296
+ expect.any(String),
297
+ undefined,
298
+ true
299
+ );
300
+ });
301
+
302
+ it('uses fallback scope when resolvedScope is null', () => {
303
+ mockUseResolvedScope.mockReturnValue({
304
+ resolvedScope: null,
305
+ isLoading: false,
306
+ error: null,
307
+ });
308
+
309
+ renderHook(() => useResourcePermissions('contacts'));
310
+
311
+ const fallbackScope: Scope = {
312
+ organisationId: 'org-123',
313
+ eventId: 'event-123',
314
+ appId: undefined,
315
+ };
316
+
317
+ expect(mockUseCan).toHaveBeenCalledWith(
318
+ expect.any(String),
319
+ fallbackScope,
320
+ expect.any(String),
321
+ undefined,
322
+ true
323
+ );
324
+ });
325
+
326
+ it('handles missing organisation gracefully', () => {
327
+ mockUseOrganisations.mockReturnValue({
328
+ selectedOrganisation: null,
329
+ } as any);
330
+
331
+ mockUseResolvedScope.mockReturnValue({
332
+ resolvedScope: null,
333
+ isLoading: false,
334
+ error: null,
335
+ });
336
+
337
+ renderHook(() => useResourcePermissions('contacts'));
338
+
339
+ const fallbackScope: Scope = {
340
+ organisationId: '',
341
+ eventId: 'event-123',
342
+ appId: undefined,
343
+ };
344
+
345
+ expect(mockUseCan).toHaveBeenCalledWith(
346
+ expect.any(String),
347
+ fallbackScope,
348
+ expect.any(String),
349
+ undefined,
350
+ true
351
+ );
352
+ });
353
+
354
+ it('handles missing event gracefully', () => {
355
+ mockUseEvents.mockReturnValue({
356
+ selectedEvent: null,
357
+ } as any);
358
+
359
+ mockUseResolvedScope.mockReturnValue({
360
+ resolvedScope: null,
361
+ isLoading: false,
362
+ error: null,
363
+ });
364
+
365
+ renderHook(() => useResourcePermissions('contacts'));
366
+
367
+ const fallbackScope: Scope = {
368
+ organisationId: 'org-123',
369
+ eventId: undefined,
370
+ appId: undefined,
371
+ };
372
+
373
+ expect(mockUseCan).toHaveBeenCalledWith(
374
+ expect.any(String),
375
+ fallbackScope,
376
+ expect.any(String),
377
+ undefined,
378
+ true
379
+ );
380
+ });
381
+ });
382
+
383
+ describe('Missing User Context', () => {
384
+ it('handles missing user gracefully', () => {
385
+ mockUseUnifiedAuth.mockReturnValue({
386
+ user: null,
387
+ supabase: mockSupabase,
388
+ } as any);
389
+
390
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
391
+
392
+ expect(mockUseCan).toHaveBeenCalledWith(
393
+ '',
394
+ expect.any(Object),
395
+ expect.any(String),
396
+ undefined,
397
+ true
398
+ );
399
+ });
400
+ });
401
+
402
+ describe('Optional Event Provider', () => {
403
+ it('handles missing event provider gracefully', () => {
404
+ mockUseEvents.mockImplementation(() => {
405
+ throw new Error('Event provider not available');
406
+ });
407
+
408
+ mockUseResolvedScope.mockReturnValue({
409
+ resolvedScope: mockScope,
410
+ isLoading: false,
411
+ error: null,
412
+ });
413
+
414
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
415
+
416
+ expect(result.current.canCreate).toBeTypeOf('function');
417
+ expect(result.current.scope).toEqual(mockScope);
418
+ });
419
+ });
420
+
421
+ describe('Loading States', () => {
422
+ it('aggregates loading states from scope resolution and permission checks', () => {
423
+ mockUseResolvedScope.mockReturnValue({
424
+ resolvedScope: mockScope,
425
+ isLoading: true,
426
+ error: null,
427
+ });
428
+
429
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
430
+
431
+ expect(result.current.isLoading).toBe(true);
432
+ });
433
+
434
+ it('aggregates loading states from permission checks', () => {
435
+ mockUseCan.mockImplementation((userId, scope, permission) => {
436
+ if (permission === 'create:contacts') {
437
+ return {
438
+ can: false,
439
+ isLoading: true,
440
+ error: null,
441
+ refetch: vi.fn(),
442
+ } as any;
443
+ }
444
+ return {
445
+ can: false,
446
+ isLoading: false,
447
+ error: null,
448
+ refetch: vi.fn(),
449
+ } as any;
450
+ });
451
+
452
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
453
+
454
+ expect(result.current.isLoading).toBe(true);
455
+ });
456
+
457
+ it('includes read loading state when enableRead is true', () => {
458
+ mockUseCan.mockImplementation((userId, scope, permission) => {
459
+ if (permission === 'read:contacts') {
460
+ return {
461
+ can: false,
462
+ isLoading: true,
463
+ error: null,
464
+ refetch: vi.fn(),
465
+ } as any;
466
+ }
467
+ return {
468
+ can: false,
469
+ isLoading: false,
470
+ error: null,
471
+ refetch: vi.fn(),
472
+ } as any;
473
+ });
474
+
475
+ const { result } = renderHook(() =>
476
+ useResourcePermissions('contacts', { enableRead: true })
477
+ );
478
+
479
+ expect(result.current.isLoading).toBe(true);
480
+ });
481
+
482
+ it('excludes read loading state when enableRead is false', () => {
483
+ mockUseCan.mockImplementation((userId, scope, permission) => {
484
+ if (permission === 'read:contacts') {
485
+ return {
486
+ can: false,
487
+ isLoading: true,
488
+ error: null,
489
+ refetch: vi.fn(),
490
+ } as any;
491
+ }
492
+ return {
493
+ can: false,
494
+ isLoading: false,
495
+ error: null,
496
+ refetch: vi.fn(),
497
+ } as any;
498
+ });
499
+
500
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
501
+
502
+ expect(result.current.isLoading).toBe(false);
503
+ });
504
+ });
505
+
506
+ describe('Error Handling', () => {
507
+ it('aggregates errors from scope resolution', () => {
508
+ const scopeError = new Error('Scope resolution failed');
509
+ mockUseResolvedScope.mockReturnValue({
510
+ resolvedScope: null,
511
+ isLoading: false,
512
+ error: scopeError,
513
+ });
514
+
515
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
516
+
517
+ expect(result.current.error).toEqual(scopeError);
518
+ });
519
+
520
+ it('aggregates errors from permission checks', () => {
521
+ const permissionError = new Error('Permission check failed');
522
+ mockUseCan.mockImplementation((userId, scope, permission) => {
523
+ if (permission === 'create:contacts') {
524
+ return {
525
+ can: false,
526
+ isLoading: false,
527
+ error: permissionError,
528
+ refetch: vi.fn(),
529
+ } as any;
530
+ }
531
+ return {
532
+ can: false,
533
+ isLoading: false,
534
+ error: null,
535
+ refetch: vi.fn(),
536
+ } as any;
537
+ });
538
+
539
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
540
+
541
+ expect(result.current.error).toEqual(permissionError);
542
+ });
543
+
544
+ it('prefers scope error over permission errors', () => {
545
+ const scopeError = new Error('Scope resolution failed');
546
+ const permissionError = new Error('Permission check failed');
547
+
548
+ mockUseResolvedScope.mockReturnValue({
549
+ resolvedScope: null,
550
+ isLoading: false,
551
+ error: scopeError,
552
+ });
553
+
554
+ mockUseCan.mockImplementation((userId, scope, permission) => {
555
+ if (permission === 'create:contacts') {
556
+ return {
557
+ can: false,
558
+ isLoading: false,
559
+ error: permissionError,
560
+ refetch: vi.fn(),
561
+ } as any;
562
+ }
563
+ return {
564
+ can: false,
565
+ isLoading: false,
566
+ error: null,
567
+ refetch: vi.fn(),
568
+ } as any;
569
+ });
570
+
571
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
572
+
573
+ expect(result.current.error).toEqual(scopeError);
574
+ });
575
+
576
+ it('returns null error when no errors occur', () => {
577
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
578
+
579
+ expect(result.current.error).toBeNull();
580
+ });
581
+ });
582
+
583
+ describe('Options', () => {
584
+ it('respects enableRead option', () => {
585
+ renderHook(() => useResourcePermissions('contacts', { enableRead: true }));
586
+
587
+ // Should still call useCan for read permission
588
+ expect(mockUseCan).toHaveBeenCalledWith(
589
+ expect.any(String),
590
+ expect.any(Object),
591
+ 'read:contacts',
592
+ undefined,
593
+ true
594
+ );
595
+ });
596
+
597
+ it('defaults enableRead to false', () => {
598
+ const { result } = renderHook(() => useResourcePermissions('contacts'));
599
+
600
+ // canRead should return true when enableRead is false
601
+ expect(result.current.canRead('contacts')).toBe(true);
602
+ });
603
+ });
604
+
605
+ describe('Different Resources', () => {
606
+ it('works with different resource names', () => {
607
+ renderHook(() => useResourcePermissions('risks'));
608
+
609
+ expect(mockUseCan).toHaveBeenCalledWith(
610
+ expect.any(String),
611
+ expect.any(Object),
612
+ 'create:risks',
613
+ undefined,
614
+ true
615
+ );
616
+ expect(mockUseCan).toHaveBeenCalledWith(
617
+ expect.any(String),
618
+ expect.any(Object),
619
+ 'update:risks',
620
+ undefined,
621
+ true
622
+ );
623
+ expect(mockUseCan).toHaveBeenCalledWith(
624
+ expect.any(String),
625
+ expect.any(Object),
626
+ 'delete:risks',
627
+ undefined,
628
+ true
629
+ );
630
+ });
631
+ });
632
+ });
633
+