@jmruthers/pace-core 0.5.140 → 0.5.142

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 (180) hide show
  1. package/README.md +2 -2
  2. package/dist/{DataTable-JXFCA2BJ.js → DataTable-SKCX4SCB.js} +6 -6
  3. package/dist/{EventLogo-rFL_kRjk.d.ts → EventLogo-B3V3otev.d.ts} +307 -1
  4. package/dist/{UnifiedAuthProvider-XIQQ7LVU.js → UnifiedAuthProvider-BMJAP6Z7.js} +3 -3
  5. package/dist/{chunk-22WKWKRX.js → chunk-2AKRP5QZ.js} +4 -4
  6. package/dist/{chunk-4C7EXCAR.js → chunk-CRGFNQ2L.js} +4 -4
  7. package/dist/{chunk-TLT2ZR3L.js → chunk-E6ZCVF4T.js} +4 -4
  8. package/dist/{chunk-INQLMHPF.js → chunk-ERGKJX4D.js} +2 -2
  9. package/dist/{chunk-6LAAY47Q.js → chunk-MSHEVJXS.js} +2 -2
  10. package/dist/{chunk-MA6EPSGZ.js → chunk-PKW27QVS.js} +2 -2
  11. package/dist/{chunk-T6JN6LH6.js → chunk-R53TUSFK.js} +3 -3
  12. package/dist/{chunk-6DXZ6V5Q.js → chunk-SFVL7ZFI.js} +5 -5
  13. package/dist/{chunk-5JMOHWDI.js → chunk-TUJSIWX6.js} +497 -329
  14. package/dist/chunk-TUJSIWX6.js.map +1 -0
  15. package/dist/{chunk-BOOI7GK2.js → chunk-VOJBGZYI.js} +119 -3
  16. package/dist/chunk-VOJBGZYI.js.map +1 -0
  17. package/dist/{chunk-YCWDTTUK.js → chunk-WM26XK7I.js} +22 -8
  18. package/dist/chunk-WM26XK7I.js.map +1 -0
  19. package/dist/components.d.ts +3 -1
  20. package/dist/components.js +20 -8
  21. package/dist/components.js.map +1 -1
  22. package/dist/hooks.js +7 -7
  23. package/dist/index.d.ts +4 -2
  24. package/dist/index.js +25 -11
  25. package/dist/index.js.map +1 -1
  26. package/dist/providers.js +2 -2
  27. package/dist/rbac/index.d.ts +94 -1
  28. package/dist/rbac/index.js +9 -7
  29. package/dist/utils.js +1 -1
  30. package/docs/api/README.md +2 -2
  31. package/docs/api/classes/ColumnFactory.md +1 -1
  32. package/docs/api/classes/ErrorBoundary.md +1 -1
  33. package/docs/api/classes/InvalidScopeError.md +1 -1
  34. package/docs/api/classes/MissingUserContextError.md +1 -1
  35. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  36. package/docs/api/classes/PermissionDeniedError.md +1 -1
  37. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  38. package/docs/api/classes/RBACAuditManager.md +1 -1
  39. package/docs/api/classes/RBACCache.md +1 -1
  40. package/docs/api/classes/RBACEngine.md +1 -1
  41. package/docs/api/classes/RBACError.md +1 -1
  42. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  43. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  44. package/docs/api/classes/StorageUtils.md +1 -1
  45. package/docs/api/enums/FileCategory.md +1 -1
  46. package/docs/api/interfaces/AggregateConfig.md +1 -1
  47. package/docs/api/interfaces/BadgeProps.md +1 -1
  48. package/docs/api/interfaces/ButtonProps.md +1 -1
  49. package/docs/api/interfaces/CalendarProps.md +40 -0
  50. package/docs/api/interfaces/CardProps.md +1 -1
  51. package/docs/api/interfaces/ColorPalette.md +1 -1
  52. package/docs/api/interfaces/ColorShade.md +1 -1
  53. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  54. package/docs/api/interfaces/DataRecord.md +1 -1
  55. package/docs/api/interfaces/DataTableAction.md +1 -1
  56. package/docs/api/interfaces/DataTableColumn.md +1 -1
  57. package/docs/api/interfaces/DataTableProps.md +1 -1
  58. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  59. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  60. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  61. package/docs/api/interfaces/EventAppRoleData.md +1 -1
  62. package/docs/api/interfaces/EventLogoProps.md +1 -1
  63. package/docs/api/interfaces/ExportColumn.md +1 -1
  64. package/docs/api/interfaces/ExportOptions.md +1 -1
  65. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  66. package/docs/api/interfaces/FileMetadata.md +1 -1
  67. package/docs/api/interfaces/FileReference.md +1 -1
  68. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  69. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  70. package/docs/api/interfaces/FileUploadProps.md +1 -1
  71. package/docs/api/interfaces/FooterProps.md +1 -1
  72. package/docs/api/interfaces/GrantEventAppRoleParams.md +1 -1
  73. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  74. package/docs/api/interfaces/InputProps.md +1 -1
  75. package/docs/api/interfaces/LabelProps.md +1 -1
  76. package/docs/api/interfaces/LoginFormProps.md +1 -1
  77. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  78. package/docs/api/interfaces/NavigationContextType.md +1 -1
  79. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  80. package/docs/api/interfaces/NavigationItem.md +1 -1
  81. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  82. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  83. package/docs/api/interfaces/Organisation.md +1 -1
  84. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  85. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  86. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  87. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  88. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  89. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  90. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  91. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  92. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  93. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  94. package/docs/api/interfaces/PaletteData.md +1 -1
  95. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  96. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  97. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  98. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  99. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  100. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  101. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  102. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  103. package/docs/api/interfaces/RBACConfig.md +1 -1
  104. package/docs/api/interfaces/RBACLogger.md +1 -1
  105. package/docs/api/interfaces/ResourcePermissions.md +155 -0
  106. package/docs/api/interfaces/RevokeEventAppRoleParams.md +1 -1
  107. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  108. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  109. package/docs/api/interfaces/RoleManagementResult.md +1 -1
  110. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  111. package/docs/api/interfaces/RouteConfig.md +1 -1
  112. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  113. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  114. package/docs/api/interfaces/SessionRestorationLoaderProps.md +1 -1
  115. package/docs/api/interfaces/StorageConfig.md +1 -1
  116. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  117. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  118. package/docs/api/interfaces/StorageListOptions.md +1 -1
  119. package/docs/api/interfaces/StorageListResult.md +1 -1
  120. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  121. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  122. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  123. package/docs/api/interfaces/StyleImport.md +1 -1
  124. package/docs/api/interfaces/SwitchProps.md +1 -1
  125. package/docs/api/interfaces/TabsContentProps.md +9 -0
  126. package/docs/api/interfaces/TabsListProps.md +9 -0
  127. package/docs/api/interfaces/TabsProps.md +9 -0
  128. package/docs/api/interfaces/TabsTriggerProps.md +9 -0
  129. package/docs/api/interfaces/TextareaProps.md +53 -0
  130. package/docs/api/interfaces/ToastActionElement.md +1 -1
  131. package/docs/api/interfaces/ToastProps.md +1 -1
  132. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  133. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  134. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  135. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  136. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  137. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  138. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  139. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  140. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  141. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  142. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  143. package/docs/api/interfaces/UseResourcePermissionsOptions.md +34 -0
  144. package/docs/api/interfaces/UserEventAccess.md +1 -1
  145. package/docs/api/interfaces/UserMenuProps.md +1 -1
  146. package/docs/api/interfaces/UserProfile.md +1 -1
  147. package/docs/api/modules.md +289 -2
  148. package/docs/rbac/README.md +2 -1
  149. package/docs/rbac/event-based-apps.md +872 -0
  150. package/package.json +3 -1
  151. package/src/components/Calendar/Calendar.test.tsx +338 -0
  152. package/src/components/Calendar/Calendar.tsx +192 -0
  153. package/src/components/Calendar/index.ts +10 -0
  154. package/src/components/Tabs/Tabs.test.tsx +439 -0
  155. package/src/components/Tabs/Tabs.tsx +202 -0
  156. package/src/components/Tabs/index.ts +10 -0
  157. package/src/components/Textarea/Textarea.test.tsx +269 -0
  158. package/src/components/Textarea/Textarea.tsx +133 -0
  159. package/src/components/Textarea/index.ts +10 -0
  160. package/src/components/index.ts +11 -0
  161. package/src/index.ts +11 -0
  162. package/src/rbac/hooks/index.ts +2 -0
  163. package/src/rbac/hooks/useResourcePermissions.test.ts +633 -0
  164. package/src/rbac/hooks/useResourcePermissions.ts +235 -0
  165. package/src/services/EventService.ts +29 -8
  166. package/src/services/__tests__/EventService.test.ts +48 -8
  167. package/dist/chunk-5JMOHWDI.js.map +0 -1
  168. package/dist/chunk-BOOI7GK2.js.map +0 -1
  169. package/dist/chunk-YCWDTTUK.js.map +0 -1
  170. package/src/rbac/docs/event-based-apps.md +0 -285
  171. /package/dist/{DataTable-JXFCA2BJ.js.map → DataTable-SKCX4SCB.js.map} +0 -0
  172. /package/dist/{UnifiedAuthProvider-XIQQ7LVU.js.map → UnifiedAuthProvider-BMJAP6Z7.js.map} +0 -0
  173. /package/dist/{chunk-22WKWKRX.js.map → chunk-2AKRP5QZ.js.map} +0 -0
  174. /package/dist/{chunk-4C7EXCAR.js.map → chunk-CRGFNQ2L.js.map} +0 -0
  175. /package/dist/{chunk-TLT2ZR3L.js.map → chunk-E6ZCVF4T.js.map} +0 -0
  176. /package/dist/{chunk-INQLMHPF.js.map → chunk-ERGKJX4D.js.map} +0 -0
  177. /package/dist/{chunk-6LAAY47Q.js.map → chunk-MSHEVJXS.js.map} +0 -0
  178. /package/dist/{chunk-MA6EPSGZ.js.map → chunk-PKW27QVS.js.map} +0 -0
  179. /package/dist/{chunk-T6JN6LH6.js.map → chunk-R53TUSFK.js.map} +0 -0
  180. /package/dist/{chunk-6DXZ6V5Q.js.map → chunk-SFVL7ZFI.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
+