@jmruthers/pace-core 0.5.114 → 0.5.116

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 (236) hide show
  1. package/dist/{AuthService-CVgsgtaZ.d.ts → AuthService-D4646R4b.d.ts} +9 -4
  2. package/dist/{DataTable-3JRLZXER.js → DataTable-ZOAKQ3SU.js} +10 -9
  3. package/dist/{UnifiedAuthProvider-KZZUO27W.js → UnifiedAuthProvider-YFN7YGVN.js} +4 -3
  4. package/dist/{api-PKU4PUBO.js → api-TNIBJWLM.js} +3 -3
  5. package/dist/{audit-H4YJJF7R.js → audit-T36HM7IM.js} +2 -2
  6. package/dist/{chunk-4OX5PXHX.js → chunk-2GJ5GL77.js} +4 -5
  7. package/dist/chunk-2GJ5GL77.js.map +1 -0
  8. package/dist/{chunk-5YIZFEUQ.js → chunk-2LM4QQGH.js} +31 -35
  9. package/dist/chunk-2LM4QQGH.js.map +1 -0
  10. package/dist/{chunk-3OGQLOJM.js → chunk-3DBFLLLU.js} +30 -1
  11. package/dist/chunk-3DBFLLLU.js.map +1 -0
  12. package/dist/{chunk-KTHLNIMA.js → chunk-ECOVPXYS.js} +13 -62
  13. package/dist/chunk-ECOVPXYS.js.map +1 -0
  14. package/dist/{chunk-OO3V7W4H.js → chunk-KA3PSVNV.js} +87 -40
  15. package/dist/chunk-KA3PSVNV.js.map +1 -0
  16. package/dist/{chunk-HKWQN44G.js → chunk-KMPWND3F.js} +15 -15
  17. package/dist/{chunk-L36JW4KV.js → chunk-LFS45U62.js} +2 -2
  18. package/dist/{chunk-NEONKMTU.js → chunk-LZYHAL7Y.js} +9 -4
  19. package/dist/{chunk-NEONKMTU.js.map → chunk-LZYHAL7Y.js.map} +1 -1
  20. package/dist/{chunk-BUN7NMV7.js → chunk-O3FTRYEU.js} +2 -2
  21. package/dist/{chunk-F6QB26OS.js → chunk-P3PUOL6B.js} +80 -8
  22. package/dist/chunk-P3PUOL6B.js.map +1 -0
  23. package/dist/{chunk-ZPXWJA4H.js → chunk-PHDAXDHB.js} +131 -5
  24. package/dist/chunk-PHDAXDHB.js.map +1 -0
  25. package/dist/chunk-UJI6WSMD.js +201 -0
  26. package/dist/{chunk-5CDJCTOO.js.map → chunk-UJI6WSMD.js.map} +1 -1
  27. package/dist/{chunk-JHWQNJP3.js → chunk-UKZWNQMB.js} +65 -19
  28. package/dist/{chunk-JHWQNJP3.js.map → chunk-UKZWNQMB.js.map} +1 -1
  29. package/dist/{chunk-7H75SHXZ.js → chunk-VN3OOE35.js} +2 -2
  30. package/dist/{chunk-QKIVSZ2O.js → chunk-WP5I5GLN.js} +2 -2
  31. package/dist/components.d.ts +1 -1
  32. package/dist/components.js +12 -11
  33. package/dist/components.js.map +1 -1
  34. package/dist/hooks.d.ts +1 -1
  35. package/dist/hooks.js +10 -9
  36. package/dist/hooks.js.map +1 -1
  37. package/dist/index.d.ts +4 -4
  38. package/dist/index.js +19 -16
  39. package/dist/index.js.map +1 -1
  40. package/dist/providers.d.ts +2 -2
  41. package/dist/providers.js +3 -2
  42. package/dist/rbac/index.d.ts +82 -1
  43. package/dist/rbac/index.js +13 -10
  44. package/dist/{useToast-DRah6K-g.d.ts → useToast-Cs_g32bg.d.ts} +8 -6
  45. package/dist/utils.js +6 -4
  46. package/dist/utils.js.map +1 -1
  47. package/dist/validation.js +3 -1
  48. package/dist/validation.js.map +1 -1
  49. package/docs/README.md +4 -0
  50. package/docs/api/classes/ColumnFactory.md +1 -1
  51. package/docs/api/classes/ErrorBoundary.md +1 -1
  52. package/docs/api/classes/InvalidScopeError.md +1 -1
  53. package/docs/api/classes/MissingUserContextError.md +1 -1
  54. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  55. package/docs/api/classes/PermissionDeniedError.md +1 -1
  56. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  57. package/docs/api/classes/RBACAuditManager.md +35 -12
  58. package/docs/api/classes/RBACCache.md +1 -1
  59. package/docs/api/classes/RBACEngine.md +1 -1
  60. package/docs/api/classes/RBACError.md +1 -1
  61. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  62. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  63. package/docs/api/classes/StorageUtils.md +1 -1
  64. package/docs/api/enums/FileCategory.md +1 -1
  65. package/docs/api/interfaces/AggregateConfig.md +1 -1
  66. package/docs/api/interfaces/ButtonProps.md +1 -1
  67. package/docs/api/interfaces/CardProps.md +1 -1
  68. package/docs/api/interfaces/ColorPalette.md +1 -1
  69. package/docs/api/interfaces/ColorShade.md +1 -1
  70. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  71. package/docs/api/interfaces/DataRecord.md +1 -1
  72. package/docs/api/interfaces/DataTableAction.md +1 -1
  73. package/docs/api/interfaces/DataTableColumn.md +1 -1
  74. package/docs/api/interfaces/DataTableProps.md +1 -1
  75. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  76. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  77. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  78. package/docs/api/interfaces/EventAppRoleData.md +71 -0
  79. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  80. package/docs/api/interfaces/FileMetadata.md +1 -1
  81. package/docs/api/interfaces/FileReference.md +1 -1
  82. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  83. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  84. package/docs/api/interfaces/FileUploadProps.md +1 -1
  85. package/docs/api/interfaces/FooterProps.md +1 -1
  86. package/docs/api/interfaces/GrantEventAppRoleParams.md +122 -0
  87. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  88. package/docs/api/interfaces/InputProps.md +1 -1
  89. package/docs/api/interfaces/LabelProps.md +1 -1
  90. package/docs/api/interfaces/LoginFormProps.md +1 -1
  91. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  92. package/docs/api/interfaces/NavigationContextType.md +1 -1
  93. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  94. package/docs/api/interfaces/NavigationItem.md +1 -1
  95. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  96. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  97. package/docs/api/interfaces/Organisation.md +1 -1
  98. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  99. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  100. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  101. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  102. package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
  103. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  104. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  105. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  106. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  107. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  108. package/docs/api/interfaces/PaletteData.md +1 -1
  109. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  110. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  111. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  112. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  113. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  114. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  115. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  116. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  117. package/docs/api/interfaces/RBACConfig.md +1 -1
  118. package/docs/api/interfaces/RBACLogger.md +1 -1
  119. package/docs/api/interfaces/RevokeEventAppRoleParams.md +100 -0
  120. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  121. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  122. package/docs/api/interfaces/RoleManagementResult.md +52 -0
  123. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  124. package/docs/api/interfaces/RouteConfig.md +1 -1
  125. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  126. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  127. package/docs/api/interfaces/StorageConfig.md +1 -1
  128. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  129. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  130. package/docs/api/interfaces/StorageListOptions.md +1 -1
  131. package/docs/api/interfaces/StorageListResult.md +1 -1
  132. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  133. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  134. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  135. package/docs/api/interfaces/StyleImport.md +1 -1
  136. package/docs/api/interfaces/SwitchProps.md +1 -1
  137. package/docs/api/interfaces/ToastActionElement.md +1 -1
  138. package/docs/api/interfaces/ToastProps.md +1 -1
  139. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  140. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  141. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  142. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  143. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  144. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  145. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  146. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  147. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  148. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  149. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  150. package/docs/api/interfaces/UserEventAccess.md +1 -1
  151. package/docs/api/interfaces/UserMenuProps.md +1 -1
  152. package/docs/api/interfaces/UserProfile.md +1 -1
  153. package/docs/api/modules.md +43 -16
  154. package/docs/architecture/rpc-function-standards.md +193 -0
  155. package/package.json +1 -1
  156. package/src/__tests__/TEST_STANDARD.md +244 -2
  157. package/src/components/DataTable/__tests__/a11y.basic.test.tsx +46 -16
  158. package/src/components/DataTable/__tests__/keyboard.test.tsx +276 -217
  159. package/src/components/DataTable/components/DataTableCore.tsx +32 -17
  160. package/src/components/DataTable/components/DataTableToolbar.tsx +3 -2
  161. package/src/components/DataTable/components/EditableRow.tsx +18 -1
  162. package/src/components/DataTable/components/ImportModal.tsx +25 -2
  163. package/src/components/DataTable/components/ViewRowModal.tsx +1 -1
  164. package/src/components/DataTable/components/__tests__/AccessDeniedPage.test.tsx +735 -0
  165. package/src/components/DataTable/components/__tests__/BulkOperationsDropdown.test.tsx +572 -0
  166. package/src/components/DataTable/components/__tests__/ColumnVisibilityDropdown.test.tsx +708 -0
  167. package/src/components/DataTable/components/__tests__/DataTableErrorBoundary.test.tsx +451 -0
  168. package/src/components/DataTable/components/__tests__/DataTableModals.test.tsx +456 -0
  169. package/src/components/DataTable/components/__tests__/EditableRow.test.tsx +454 -0
  170. package/src/components/DataTable/components/__tests__/ExpandButton.test.tsx +462 -0
  171. package/src/components/DataTable/components/__tests__/FilterRow.test.tsx +423 -0
  172. package/src/components/DataTable/components/__tests__/GroupHeader.test.tsx +393 -0
  173. package/src/components/DataTable/components/__tests__/GroupingDropdown.test.tsx +617 -0
  174. package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +734 -0
  175. package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +412 -0
  176. package/src/components/DataTable/hooks/useTableHandlers.ts +4 -0
  177. package/src/components/EventSelector/EventSelector.tsx +5 -25
  178. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +12 -7
  179. package/src/components/PaceAppLayout/PaceAppLayout.tsx +4 -0
  180. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +7 -2
  181. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +13 -8
  182. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +109 -100
  183. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +18 -13
  184. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +17 -12
  185. package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +2 -0
  186. package/src/components/PaceLoginPage/PaceLoginPage.tsx +11 -1
  187. package/src/components/PasswordReset/PasswordChangeForm.test.tsx +2 -2
  188. package/src/components/ProtectedRoute/ProtectedRoute.test.tsx +648 -0
  189. package/src/components/ProtectedRoute/ProtectedRoute.tsx +10 -7
  190. package/src/components/PublicLayout/__tests__/PublicErrorBoundary.test.tsx +4 -12
  191. package/src/components/Select/Select.tsx +8 -0
  192. package/src/components/Toast/Toast.test.tsx +8 -7
  193. package/src/components/Toast/Toast.tsx +4 -4
  194. package/src/hooks/__tests__/usePublicEvent.simple.test.ts +367 -3
  195. package/src/hooks/__tests__/usePublicFileDisplay.test.ts +916 -0
  196. package/src/hooks/useEventTheme.ts +49 -18
  197. package/src/hooks/usePermissionCache.ts +5 -3
  198. package/src/hooks/useSecureDataAccess.ts +11 -1
  199. package/src/hooks/useToast.ts +11 -12
  200. package/src/providers/services/EventServiceProvider.tsx +15 -8
  201. package/src/rbac/__tests__/cache-invalidation.test.ts +385 -0
  202. package/src/rbac/audit.test.ts +206 -0
  203. package/src/rbac/audit.ts +37 -2
  204. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +26 -23
  205. package/src/rbac/errors.test.ts +340 -0
  206. package/src/rbac/hooks/index.ts +9 -0
  207. package/src/rbac/hooks/useResolvedScope.test.ts +1063 -0
  208. package/src/rbac/hooks/useRoleManagement.test.ts +908 -0
  209. package/src/rbac/hooks/useRoleManagement.ts +255 -0
  210. package/src/services/AuthService.ts +10 -0
  211. package/src/services/EventService.ts +111 -50
  212. package/src/services/__tests__/AuthService.test.ts +1 -1
  213. package/src/services/__tests__/EventService.test.ts +60 -45
  214. package/src/services/interfaces/IEventService.ts +1 -1
  215. package/src/utils/__tests__/deviceFingerprint.unit.test.ts +320 -0
  216. package/src/utils/__tests__/logger.unit.test.ts +398 -0
  217. package/src/utils/__tests__/validation.unit.test.ts +225 -1
  218. package/src/utils/file-reference.test.ts +214 -0
  219. package/dist/chunk-3OGQLOJM.js.map +0 -1
  220. package/dist/chunk-4OX5PXHX.js.map +0 -1
  221. package/dist/chunk-5CDJCTOO.js +0 -190
  222. package/dist/chunk-5YIZFEUQ.js.map +0 -1
  223. package/dist/chunk-F6QB26OS.js.map +0 -1
  224. package/dist/chunk-KTHLNIMA.js.map +0 -1
  225. package/dist/chunk-OO3V7W4H.js.map +0 -1
  226. package/dist/chunk-ZPXWJA4H.js.map +0 -1
  227. package/src/rbac/audit-enhanced.ts +0 -351
  228. /package/dist/{DataTable-3JRLZXER.js.map → DataTable-ZOAKQ3SU.js.map} +0 -0
  229. /package/dist/{UnifiedAuthProvider-KZZUO27W.js.map → UnifiedAuthProvider-YFN7YGVN.js.map} +0 -0
  230. /package/dist/{api-PKU4PUBO.js.map → api-TNIBJWLM.js.map} +0 -0
  231. /package/dist/{audit-H4YJJF7R.js.map → audit-T36HM7IM.js.map} +0 -0
  232. /package/dist/{chunk-HKWQN44G.js.map → chunk-KMPWND3F.js.map} +0 -0
  233. /package/dist/{chunk-L36JW4KV.js.map → chunk-LFS45U62.js.map} +0 -0
  234. /package/dist/{chunk-BUN7NMV7.js.map → chunk-O3FTRYEU.js.map} +0 -0
  235. /package/dist/{chunk-7H75SHXZ.js.map → chunk-VN3OOE35.js.map} +0 -0
  236. /package/dist/{chunk-QKIVSZ2O.js.map → chunk-WP5I5GLN.js.map} +0 -0
@@ -0,0 +1,1063 @@
1
+ /**
2
+ * @file useResolvedScope Hook Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module RBAC/Hooks
5
+ * @since 1.0.0
6
+ *
7
+ * Comprehensive tests for the useResolvedScope hook following TEST_STANDARD.md.
8
+ * Tests focus on behavior: scope resolution from various contexts, loading states, and error handling.
9
+ */
10
+
11
+ import { renderHook, waitFor } from '@testing-library/react';
12
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
13
+ import { useResolvedScope } from './useResolvedScope';
14
+ import type { SupabaseClient } from '@supabase/supabase-js';
15
+ import type { Database } from '../../types/database';
16
+
17
+ // Mock dependencies
18
+ vi.mock('../utils/eventContext', () => ({
19
+ createScopeFromEvent: vi.fn(),
20
+ }));
21
+
22
+ vi.mock('../../utils/appNameResolver', () => ({
23
+ getCurrentAppName: vi.fn(),
24
+ }));
25
+
26
+ import { createScopeFromEvent } from '../utils/eventContext';
27
+ import { getCurrentAppName } from '../../utils/appNameResolver';
28
+ import { createMockSupabaseClient } from '../../__tests__/helpers/supabaseMock';
29
+
30
+ describe('useResolvedScope Hook', () => {
31
+ const mockCreateScopeFromEvent = vi.mocked(createScopeFromEvent);
32
+ const mockGetCurrentAppName = vi.mocked(getCurrentAppName);
33
+
34
+ let mockSupabase: SupabaseClient<Database>;
35
+ let sharedMockQuery: any;
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+
40
+ // Create a shared query builder that will be returned by from()
41
+ // Default to resolving successfully for app lookup
42
+ sharedMockQuery = {
43
+ select: vi.fn().mockReturnThis(),
44
+ eq: vi.fn().mockReturnThis(),
45
+ single: vi.fn().mockResolvedValue({
46
+ data: { id: 'app-123', name: 'test-app', is_active: true },
47
+ error: null,
48
+ }),
49
+ };
50
+
51
+ mockSupabase = {
52
+ from: vi.fn().mockReturnValue(sharedMockQuery),
53
+ rpc: vi.fn(),
54
+ } as any;
55
+
56
+ mockGetCurrentAppName.mockReturnValue('test-app');
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.clearAllMocks();
61
+ });
62
+
63
+ describe('Scope Resolution', () => {
64
+ it('resolves scope when both organisation and event are provided', async () => {
65
+ // Set up mock BEFORE rendering hook
66
+ sharedMockQuery.single.mockResolvedValue({
67
+ data: { id: 'app-123', name: 'test-app', is_active: true },
68
+ error: null,
69
+ });
70
+
71
+ const { result, rerender } = renderHook(() =>
72
+ useResolvedScope({
73
+ supabase: mockSupabase,
74
+ selectedOrganisationId: 'org-123',
75
+ selectedEventId: 'event-123',
76
+ })
77
+ );
78
+
79
+ expect(result.current.isLoading).toBe(true);
80
+ expect(result.current.resolvedScope).toBeNull();
81
+
82
+ // Wait for async app ID resolution to complete
83
+ await waitFor(
84
+ () => {
85
+ expect(result.current.isLoading).toBe(false);
86
+ },
87
+ { timeout: 2000 }
88
+ );
89
+
90
+ // The stable scope ref is updated in a useEffect that depends on resolvedScope state
91
+ // The return value checks stableScope.organisationId, so we need to wait for the ref update
92
+ // Force a re-render to pick up the ref change (refs don't trigger re-renders)
93
+ rerender();
94
+
95
+ await waitFor(
96
+ () => {
97
+ expect(result.current.resolvedScope).not.toBeNull();
98
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
99
+ },
100
+ { timeout: 2000, interval: 10 }
101
+ );
102
+
103
+ // Verify the mock was called
104
+ expect(sharedMockQuery.single).toHaveBeenCalled();
105
+
106
+ // The resolved scope should include organisation, event, and app ID
107
+ expect(result.current.resolvedScope).toEqual({
108
+ organisationId: 'org-123',
109
+ eventId: 'event-123',
110
+ appId: 'app-123',
111
+ });
112
+ expect(result.current.error).toBeNull();
113
+ });
114
+
115
+ it('resolves scope when only organisation is provided', async () => {
116
+ sharedMockQuery.single.mockResolvedValue({
117
+ data: { id: 'app-123', name: 'test-app', is_active: true },
118
+ error: null,
119
+ });
120
+
121
+ const { result, rerender } = renderHook(() =>
122
+ useResolvedScope({
123
+ supabase: mockSupabase,
124
+ selectedOrganisationId: 'org-123',
125
+ selectedEventId: null,
126
+ })
127
+ );
128
+
129
+ // Wait for async app ID resolution to complete
130
+ await waitFor(
131
+ () => {
132
+ expect(result.current.isLoading).toBe(false);
133
+ },
134
+ { timeout: 2000 }
135
+ );
136
+
137
+ // The stable scope ref is updated in a useEffect after resolvedScope state updates
138
+ // Force a rerender to pick up the ref change (refs don't trigger re-renders)
139
+ rerender();
140
+
141
+ // Wait for stable scope ref to update (happens in useEffect after state update)
142
+ await waitFor(
143
+ () => {
144
+ expect(result.current.resolvedScope).not.toBeNull();
145
+ },
146
+ { timeout: 2000, interval: 10 }
147
+ );
148
+
149
+ expect(result.current.resolvedScope).toEqual({
150
+ organisationId: 'org-123',
151
+ eventId: undefined,
152
+ appId: 'app-123',
153
+ });
154
+ expect(result.current.error).toBeNull();
155
+ });
156
+
157
+ it('resolves scope from event when only event is provided', async () => {
158
+ sharedMockQuery.single.mockResolvedValue({
159
+ data: { id: 'app-123', name: 'test-app', is_active: true },
160
+ error: null,
161
+ });
162
+
163
+ mockCreateScopeFromEvent.mockResolvedValue({
164
+ organisationId: 'org-456',
165
+ eventId: 'event-123',
166
+ appId: 'app-123',
167
+ });
168
+
169
+ const { result, rerender } = renderHook(() =>
170
+ useResolvedScope({
171
+ supabase: mockSupabase,
172
+ selectedOrganisationId: null,
173
+ selectedEventId: 'event-123',
174
+ })
175
+ );
176
+
177
+ // Wait for async app ID resolution to complete
178
+ await waitFor(
179
+ () => {
180
+ expect(result.current.isLoading).toBe(false);
181
+ },
182
+ { timeout: 2000 }
183
+ );
184
+
185
+ // The stable scope ref is updated in a useEffect after resolvedScope state updates
186
+ // Force a rerender to pick up the ref change (refs don't trigger re-renders)
187
+ rerender();
188
+
189
+ // Wait for stable scope ref to update (happens in useEffect after state update)
190
+ await waitFor(
191
+ () => {
192
+ expect(result.current.resolvedScope).not.toBeNull();
193
+ },
194
+ { timeout: 2000, interval: 10 }
195
+ );
196
+
197
+ expect(result.current.resolvedScope).toEqual({
198
+ organisationId: 'org-456',
199
+ eventId: 'event-123',
200
+ appId: 'app-123',
201
+ });
202
+ expect(result.current.error).toBeNull();
203
+ expect(mockCreateScopeFromEvent).toHaveBeenCalledWith(
204
+ mockSupabase,
205
+ 'event-123',
206
+ 'app-123'
207
+ );
208
+ });
209
+
210
+ it('handles no context available', async () => {
211
+ const { result } = renderHook(() =>
212
+ useResolvedScope({
213
+ supabase: mockSupabase,
214
+ selectedOrganisationId: null,
215
+ selectedEventId: null,
216
+ })
217
+ );
218
+
219
+ await waitFor(
220
+ () => {
221
+ expect(result.current.isLoading).toBe(false);
222
+ },
223
+ { timeout: 2000 }
224
+ );
225
+
226
+ expect(result.current.resolvedScope).toBeNull();
227
+ expect(result.current.error).toBeInstanceOf(Error);
228
+ expect(result.current.error?.message).toBe(
229
+ 'No organisation or event context available'
230
+ );
231
+ });
232
+ });
233
+
234
+ describe('App ID Resolution', () => {
235
+ it('resolves app ID from database when app name is available', async () => {
236
+ sharedMockQuery.single.mockResolvedValue({
237
+ data: { id: 'app-123', name: 'test-app', is_active: true },
238
+ error: null,
239
+ });
240
+
241
+ const { result, rerender } = renderHook(() =>
242
+ useResolvedScope({
243
+ supabase: mockSupabase,
244
+ selectedOrganisationId: 'org-123',
245
+ selectedEventId: 'event-123',
246
+ })
247
+ );
248
+
249
+ // Wait for async app ID resolution to complete
250
+ await waitFor(
251
+ () => {
252
+ expect(result.current.isLoading).toBe(false);
253
+ },
254
+ { timeout: 2000 }
255
+ );
256
+
257
+ // The stable scope ref is updated in a useEffect after resolvedScope state updates
258
+ // Force a rerender to pick up the ref change (refs don't trigger re-renders)
259
+ rerender();
260
+
261
+ // Wait for stable scope ref to update (happens in useEffect after state update)
262
+ await waitFor(
263
+ () => {
264
+ expect(result.current.resolvedScope).not.toBeNull();
265
+ },
266
+ { timeout: 2000, interval: 10 }
267
+ );
268
+
269
+ expect(result.current.resolvedScope?.appId).toBe('app-123');
270
+ });
271
+
272
+ it('handles app not found in database', async () => {
273
+ sharedMockQuery.single.mockResolvedValue({
274
+ data: null,
275
+ error: { message: 'App not found' },
276
+ });
277
+
278
+ // Mock inactive app check - second call uses same query builder
279
+ sharedMockQuery.single.mockResolvedValueOnce({
280
+ data: null,
281
+ error: null,
282
+ });
283
+
284
+ const { result, rerender } = renderHook(() =>
285
+ useResolvedScope({
286
+ supabase: mockSupabase,
287
+ selectedOrganisationId: 'org-123',
288
+ selectedEventId: 'event-123',
289
+ })
290
+ );
291
+
292
+ await waitFor(
293
+ () => {
294
+ expect(result.current.isLoading).toBe(false);
295
+ },
296
+ { timeout: 2000 }
297
+ );
298
+
299
+ // Force rerender to pick up ref update - need to pass props
300
+ rerender({
301
+ supabase: mockSupabase,
302
+ selectedOrganisationId: 'org-123',
303
+ selectedEventId: 'event-123',
304
+ });
305
+
306
+ await waitFor(
307
+ () => {
308
+ expect(result.current.resolvedScope).not.toBeNull();
309
+ },
310
+ { timeout: 2000, interval: 10 }
311
+ );
312
+
313
+ // Should still resolve scope without app ID
314
+ // Note: appId is set to empty string '' when not provided (not undefined)
315
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
316
+ expect(result.current.resolvedScope?.appId).toBe('');
317
+ });
318
+
319
+ it('handles inactive app', async () => {
320
+ // First call (with is_active=true filter) returns error
321
+ // Second call (without is_active filter) returns inactive app
322
+ sharedMockQuery.single
323
+ .mockResolvedValueOnce({
324
+ data: null,
325
+ error: { message: 'App not found' },
326
+ })
327
+ .mockResolvedValueOnce({
328
+ data: { id: 'app-123', name: 'test-app', is_active: false },
329
+ error: null,
330
+ });
331
+
332
+ const { result, rerender } = renderHook(() =>
333
+ useResolvedScope({
334
+ supabase: mockSupabase,
335
+ selectedOrganisationId: 'org-123',
336
+ selectedEventId: 'event-123',
337
+ })
338
+ );
339
+
340
+ await waitFor(
341
+ () => {
342
+ expect(result.current.isLoading).toBe(false);
343
+ },
344
+ { timeout: 2000 }
345
+ );
346
+
347
+ // Force rerender to pick up ref update - need to pass props
348
+ rerender({
349
+ supabase: mockSupabase,
350
+ selectedOrganisationId: 'org-123',
351
+ selectedEventId: 'event-123',
352
+ });
353
+
354
+ await waitFor(
355
+ () => {
356
+ expect(result.current.resolvedScope).not.toBeNull();
357
+ },
358
+ { timeout: 2000, interval: 10 }
359
+ );
360
+
361
+ // Should still resolve scope without app ID (app is inactive)
362
+ // Note: appId is set to empty string '' when not provided (not undefined)
363
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
364
+ expect(result.current.resolvedScope?.appId).toBe('');
365
+ });
366
+
367
+ it('handles missing app name', async () => {
368
+ mockGetCurrentAppName.mockReturnValue(null);
369
+
370
+ const { result, rerender } = renderHook(() =>
371
+ useResolvedScope({
372
+ supabase: mockSupabase,
373
+ selectedOrganisationId: 'org-123',
374
+ selectedEventId: 'event-123',
375
+ })
376
+ );
377
+
378
+ await waitFor(
379
+ () => {
380
+ expect(result.current.isLoading).toBe(false);
381
+ },
382
+ { timeout: 2000 }
383
+ );
384
+
385
+ // Force rerender to pick up ref update - need to pass props
386
+ rerender({
387
+ supabase: mockSupabase,
388
+ selectedOrganisationId: 'org-123',
389
+ selectedEventId: 'event-123',
390
+ });
391
+
392
+ await waitFor(
393
+ () => {
394
+ expect(result.current.resolvedScope).not.toBeNull();
395
+ },
396
+ { timeout: 2000, interval: 10 }
397
+ );
398
+
399
+ // Note: appId is set to empty string '' when not provided (not undefined)
400
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
401
+ expect(result.current.resolvedScope?.appId).toBe('');
402
+ });
403
+
404
+ it('handles null supabase client', async () => {
405
+ const { result, rerender } = renderHook(() =>
406
+ useResolvedScope({
407
+ supabase: null,
408
+ selectedOrganisationId: 'org-123',
409
+ selectedEventId: 'event-123',
410
+ })
411
+ );
412
+
413
+ await waitFor(
414
+ () => {
415
+ expect(result.current.isLoading).toBe(false);
416
+ },
417
+ { timeout: 2000 }
418
+ );
419
+
420
+ // Force rerender to pick up ref update - need to pass props
421
+ rerender({
422
+ supabase: mockSupabase,
423
+ selectedOrganisationId: 'org-123',
424
+ selectedEventId: 'event-123',
425
+ });
426
+
427
+ await waitFor(
428
+ () => {
429
+ expect(result.current.resolvedScope).not.toBeNull();
430
+ },
431
+ { timeout: 2000, interval: 10 }
432
+ );
433
+
434
+ // Should resolve scope without app ID when supabase is null
435
+ // Note: appId is set to empty string '' when not provided (not undefined)
436
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
437
+ expect(result.current.resolvedScope?.appId).toBe('');
438
+ });
439
+ });
440
+
441
+ describe('Error Handling', () => {
442
+ it('handles error when event scope resolution fails', async () => {
443
+ const error = new Error('Failed to resolve event scope');
444
+ mockCreateScopeFromEvent.mockResolvedValue(null);
445
+
446
+ const { result } = renderHook(() =>
447
+ useResolvedScope({
448
+ supabase: mockSupabase,
449
+ selectedOrganisationId: null,
450
+ selectedEventId: 'event-123',
451
+ })
452
+ );
453
+
454
+ await waitFor(
455
+ () => {
456
+ expect(result.current.isLoading).toBe(false);
457
+ },
458
+ { timeout: 2000 }
459
+ );
460
+
461
+ expect(result.current.resolvedScope).toBeNull();
462
+ expect(result.current.error).toBeInstanceOf(Error);
463
+ expect(result.current.error?.message).toBe(
464
+ 'Could not resolve organisation from event context'
465
+ );
466
+ });
467
+
468
+ it('handles error when createScopeFromEvent throws', async () => {
469
+ const error = new Error('Database error');
470
+ mockCreateScopeFromEvent.mockRejectedValue(error);
471
+
472
+ const { result } = renderHook(() =>
473
+ useResolvedScope({
474
+ supabase: mockSupabase,
475
+ selectedOrganisationId: null,
476
+ selectedEventId: 'event-123',
477
+ })
478
+ );
479
+
480
+ await waitFor(
481
+ () => {
482
+ expect(result.current.isLoading).toBe(false);
483
+ },
484
+ { timeout: 2000 }
485
+ );
486
+
487
+ expect(result.current.resolvedScope).toBeNull();
488
+ expect(result.current.error).toEqual(error);
489
+ });
490
+
491
+ it('handles database error when resolving app ID', async () => {
492
+ sharedMockQuery.single.mockRejectedValueOnce(
493
+ new Error('Database connection failed')
494
+ );
495
+
496
+ const { result, rerender } = renderHook(() =>
497
+ useResolvedScope({
498
+ supabase: mockSupabase,
499
+ selectedOrganisationId: 'org-123',
500
+ selectedEventId: 'event-123',
501
+ })
502
+ );
503
+
504
+ await waitFor(
505
+ () => {
506
+ expect(result.current.isLoading).toBe(false);
507
+ },
508
+ { timeout: 2000 }
509
+ );
510
+
511
+ // Force rerender to pick up ref update - need to pass props
512
+ rerender({
513
+ supabase: mockSupabase,
514
+ selectedOrganisationId: 'org-123',
515
+ selectedEventId: 'event-123',
516
+ });
517
+
518
+ await waitFor(
519
+ () => {
520
+ expect(result.current.resolvedScope).not.toBeNull();
521
+ },
522
+ { timeout: 2000, interval: 10 }
523
+ );
524
+
525
+ // Should still resolve scope without app ID
526
+ // Note: appId is set to empty string '' when not provided (not undefined)
527
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
528
+ expect(result.current.resolvedScope?.appId).toBe('');
529
+ });
530
+ });
531
+
532
+ describe('Dependency Changes', () => {
533
+ it('refetches when organisation ID changes', async () => {
534
+ sharedMockQuery.single.mockResolvedValue({
535
+ data: { id: 'app-123', name: 'test-app', is_active: true },
536
+ error: null,
537
+ });
538
+
539
+ const { result, rerender } = renderHook(
540
+ ({ selectedOrganisationId, selectedEventId }) =>
541
+ useResolvedScope({
542
+ supabase: mockSupabase,
543
+ selectedOrganisationId,
544
+ selectedEventId,
545
+ }),
546
+ {
547
+ initialProps: {
548
+ selectedOrganisationId: 'org-123',
549
+ selectedEventId: 'event-123',
550
+ },
551
+ }
552
+ );
553
+
554
+ await waitFor(
555
+ () => {
556
+ expect(result.current.isLoading).toBe(false);
557
+ },
558
+ { timeout: 2000 }
559
+ );
560
+
561
+ // Force rerender to pick up ref update - need to pass current props
562
+ rerender({
563
+ supabase: mockSupabase,
564
+ selectedOrganisationId: 'org-123',
565
+ selectedEventId: 'event-123',
566
+ });
567
+
568
+ await waitFor(
569
+ () => {
570
+ expect(result.current.resolvedScope).not.toBeNull();
571
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
572
+ },
573
+ { timeout: 2000, interval: 10 }
574
+ );
575
+
576
+ // Change organisation
577
+ rerender({
578
+ supabase: mockSupabase,
579
+ selectedOrganisationId: 'org-456',
580
+ selectedEventId: 'event-123',
581
+ });
582
+
583
+ await waitFor(
584
+ () => {
585
+ expect(result.current.isLoading).toBe(false);
586
+ },
587
+ { timeout: 2000 }
588
+ );
589
+
590
+ // Force rerender to pick up ref update - need to pass current props
591
+ rerender({
592
+ supabase: mockSupabase,
593
+ selectedOrganisationId: 'org-456',
594
+ selectedEventId: 'event-123',
595
+ });
596
+
597
+ await waitFor(
598
+ () => {
599
+ expect(result.current.resolvedScope?.organisationId).toBe('org-456');
600
+ },
601
+ { timeout: 2000, interval: 10 }
602
+ );
603
+ });
604
+
605
+ it('refetches when event ID changes', async () => {
606
+ sharedMockQuery.single.mockResolvedValue({
607
+ data: { id: 'app-123', name: 'test-app', is_active: true },
608
+ error: null,
609
+ });
610
+
611
+ const { result, rerender } = renderHook(
612
+ ({ selectedOrganisationId, selectedEventId }) =>
613
+ useResolvedScope({
614
+ supabase: mockSupabase,
615
+ selectedOrganisationId,
616
+ selectedEventId,
617
+ }),
618
+ {
619
+ initialProps: {
620
+ selectedOrganisationId: 'org-123',
621
+ selectedEventId: 'event-123',
622
+ },
623
+ }
624
+ );
625
+
626
+ await waitFor(
627
+ () => {
628
+ expect(result.current.isLoading).toBe(false);
629
+ },
630
+ { timeout: 2000 }
631
+ );
632
+
633
+ // Force rerender to pick up ref update - need to pass current props
634
+ rerender({
635
+ supabase: mockSupabase,
636
+ selectedOrganisationId: 'org-123',
637
+ selectedEventId: 'event-123',
638
+ });
639
+
640
+ await waitFor(
641
+ () => {
642
+ expect(result.current.resolvedScope).not.toBeNull();
643
+ expect(result.current.resolvedScope?.eventId).toBe('event-123');
644
+ },
645
+ { timeout: 2000, interval: 10 }
646
+ );
647
+
648
+ // Change event
649
+ rerender({
650
+ supabase: mockSupabase,
651
+ selectedOrganisationId: 'org-123',
652
+ selectedEventId: 'event-456',
653
+ });
654
+
655
+ await waitFor(
656
+ () => {
657
+ expect(result.current.isLoading).toBe(false);
658
+ },
659
+ { timeout: 2000 }
660
+ );
661
+
662
+ // Force rerender to pick up ref update - need to pass current props
663
+ rerender({
664
+ supabase: mockSupabase,
665
+ selectedOrganisationId: 'org-123',
666
+ selectedEventId: 'event-456',
667
+ });
668
+
669
+ await waitFor(
670
+ () => {
671
+ expect(result.current.resolvedScope?.eventId).toBe('event-456');
672
+ },
673
+ { timeout: 2000, interval: 10 }
674
+ );
675
+ });
676
+
677
+ it('refetches when supabase client changes', async () => {
678
+ const mockQuery1 = (mockSupabase.from as any)('rbac_apps');
679
+ mockQuery1.select().eq().eq().single.mockResolvedValue({
680
+ data: { id: 'app-123', name: 'test-app', is_active: true },
681
+ error: null,
682
+ });
683
+
684
+ const { result, rerender } = renderHook(
685
+ ({ supabase, selectedOrganisationId, selectedEventId }) =>
686
+ useResolvedScope({
687
+ supabase,
688
+ selectedOrganisationId,
689
+ selectedEventId,
690
+ }),
691
+ {
692
+ initialProps: {
693
+ supabase: mockSupabase,
694
+ selectedOrganisationId: 'org-123',
695
+ selectedEventId: 'event-123',
696
+ },
697
+ }
698
+ );
699
+
700
+ await waitFor(
701
+ () => {
702
+ expect(result.current.isLoading).toBe(false);
703
+ },
704
+ { timeout: 2000 }
705
+ );
706
+
707
+ // Force rerender to pick up ref update - need to pass current props
708
+ rerender({
709
+ supabase: mockSupabase,
710
+ selectedOrganisationId: 'org-123',
711
+ selectedEventId: 'event-123',
712
+ });
713
+
714
+ await waitFor(
715
+ () => {
716
+ expect(result.current.resolvedScope).not.toBeNull();
717
+ },
718
+ { timeout: 2000, interval: 10 }
719
+ );
720
+
721
+ // Change supabase client - create new mock with shared query builder
722
+ const newSupabaseQuery = {
723
+ select: vi.fn().mockReturnThis(),
724
+ eq: vi.fn().mockReturnThis(),
725
+ single: vi.fn().mockResolvedValue({
726
+ data: { id: 'app-456', name: 'test-app', is_active: true },
727
+ error: null,
728
+ }),
729
+ };
730
+ const newSupabase = {
731
+ from: vi.fn().mockReturnValue(newSupabaseQuery),
732
+ rpc: vi.fn(),
733
+ } as any;
734
+
735
+ rerender({
736
+ supabase: newSupabase,
737
+ selectedOrganisationId: 'org-123',
738
+ selectedEventId: 'event-123',
739
+ });
740
+
741
+ await waitFor(
742
+ () => {
743
+ expect(result.current.isLoading).toBe(false);
744
+ },
745
+ { timeout: 2000 }
746
+ );
747
+
748
+ // Force rerender to pick up ref update - need to pass current props
749
+ rerender({
750
+ supabase: newSupabase,
751
+ selectedOrganisationId: 'org-123',
752
+ selectedEventId: 'event-123',
753
+ });
754
+
755
+ await waitFor(
756
+ () => {
757
+ expect(result.current.resolvedScope?.appId).toBe('app-456');
758
+ },
759
+ { timeout: 2000, interval: 10 }
760
+ );
761
+ });
762
+ });
763
+
764
+ describe('Edge Cases', () => {
765
+ it('handles empty string organisation ID', async () => {
766
+ const { result } = renderHook(() =>
767
+ useResolvedScope({
768
+ supabase: mockSupabase,
769
+ selectedOrganisationId: '',
770
+ selectedEventId: null,
771
+ })
772
+ );
773
+
774
+ await waitFor(
775
+ () => {
776
+ expect(result.current.isLoading).toBe(false);
777
+ },
778
+ { timeout: 2000 }
779
+ );
780
+
781
+ expect(result.current.resolvedScope).toBeNull();
782
+ expect(result.current.error).toBeInstanceOf(Error);
783
+ });
784
+
785
+ it('handles empty string event ID', async () => {
786
+ sharedMockQuery.single.mockResolvedValue({
787
+ data: { id: 'app-123', name: 'test-app', is_active: true },
788
+ error: null,
789
+ });
790
+
791
+ const { result, rerender } = renderHook(() =>
792
+ useResolvedScope({
793
+ supabase: mockSupabase,
794
+ selectedOrganisationId: 'org-123',
795
+ selectedEventId: '',
796
+ })
797
+ );
798
+
799
+ await waitFor(
800
+ () => {
801
+ expect(result.current.isLoading).toBe(false);
802
+ },
803
+ { timeout: 2000 }
804
+ );
805
+
806
+ // Force rerender to pick up ref update - need to pass props
807
+ rerender({
808
+ supabase: mockSupabase,
809
+ selectedOrganisationId: 'org-123',
810
+ selectedEventId: 'event-123',
811
+ });
812
+
813
+ await waitFor(
814
+ () => {
815
+ expect(result.current.resolvedScope).not.toBeNull();
816
+ },
817
+ { timeout: 2000, interval: 10 }
818
+ );
819
+
820
+ // Should resolve with organisation only
821
+ expect(result.current.resolvedScope?.organisationId).toBe('org-123');
822
+ expect(result.current.resolvedScope?.eventId).toBeUndefined();
823
+ });
824
+
825
+ it('preserves app ID from event scope when resolving from event', async () => {
826
+ sharedMockQuery.single.mockResolvedValue({
827
+ data: { id: 'app-123', name: 'test-app', is_active: true },
828
+ error: null,
829
+ });
830
+
831
+ mockCreateScopeFromEvent.mockResolvedValue({
832
+ organisationId: 'org-456',
833
+ eventId: 'event-123',
834
+ appId: 'app-789', // Different app ID from event scope
835
+ });
836
+
837
+ const { result, rerender } = renderHook(() =>
838
+ useResolvedScope({
839
+ supabase: mockSupabase,
840
+ selectedOrganisationId: null,
841
+ selectedEventId: 'event-123',
842
+ })
843
+ );
844
+
845
+ await waitFor(
846
+ () => {
847
+ expect(result.current.isLoading).toBe(false);
848
+ },
849
+ { timeout: 2000 }
850
+ );
851
+
852
+ // Force rerender to pick up ref update - need to pass props
853
+ rerender({
854
+ supabase: mockSupabase,
855
+ selectedOrganisationId: 'org-123',
856
+ selectedEventId: 'event-123',
857
+ });
858
+
859
+ await waitFor(
860
+ () => {
861
+ expect(result.current.resolvedScope).not.toBeNull();
862
+ },
863
+ { timeout: 2000, interval: 10 }
864
+ );
865
+
866
+ // Should use resolved app ID (app-123) over event scope app ID
867
+ expect(result.current.resolvedScope?.appId).toBe('app-123');
868
+ });
869
+
870
+ it('uses event scope app ID when app ID not resolved from database', async () => {
871
+ sharedMockQuery.single.mockResolvedValue({
872
+ data: null,
873
+ error: { message: 'App not found' },
874
+ });
875
+
876
+ // Mock inactive app check - second call uses same query builder
877
+ sharedMockQuery.single.mockResolvedValueOnce({
878
+ data: null,
879
+ error: null,
880
+ });
881
+
882
+ mockCreateScopeFromEvent.mockResolvedValue({
883
+ organisationId: 'org-456',
884
+ eventId: 'event-123',
885
+ appId: 'app-789', // App ID from event scope
886
+ });
887
+
888
+ const { result, rerender } = renderHook(() =>
889
+ useResolvedScope({
890
+ supabase: mockSupabase,
891
+ selectedOrganisationId: null,
892
+ selectedEventId: 'event-123',
893
+ })
894
+ );
895
+
896
+ await waitFor(
897
+ () => {
898
+ expect(result.current.isLoading).toBe(false);
899
+ },
900
+ { timeout: 2000 }
901
+ );
902
+
903
+ // Force rerender to pick up ref update - need to pass props
904
+ rerender({
905
+ supabase: mockSupabase,
906
+ selectedOrganisationId: 'org-123',
907
+ selectedEventId: 'event-123',
908
+ });
909
+
910
+ await waitFor(
911
+ () => {
912
+ expect(result.current.resolvedScope).not.toBeNull();
913
+ },
914
+ { timeout: 2000, interval: 10 }
915
+ );
916
+
917
+ // Should use event scope app ID when database resolution fails
918
+ expect(result.current.resolvedScope?.appId).toBe('app-789');
919
+ });
920
+ });
921
+
922
+ describe('Loading States', () => {
923
+ it('shows loading state during initial resolution', () => {
924
+ sharedMockQuery.single.mockImplementation(
925
+ () => new Promise(() => {}) // Never resolves
926
+ );
927
+
928
+ const { result } = renderHook(() =>
929
+ useResolvedScope({
930
+ supabase: mockSupabase,
931
+ selectedOrganisationId: 'org-123',
932
+ selectedEventId: 'event-123',
933
+ })
934
+ );
935
+
936
+ expect(result.current.isLoading).toBe(true);
937
+ expect(result.current.resolvedScope).toBeNull();
938
+ expect(result.current.error).toBeNull();
939
+ });
940
+
941
+ it('shows loading state during refetch', async () => {
942
+ sharedMockQuery.single.mockResolvedValue({
943
+ data: { id: 'app-123', name: 'test-app', is_active: true },
944
+ error: null,
945
+ });
946
+
947
+ const { result, rerender } = renderHook(
948
+ ({ selectedOrganisationId, selectedEventId }) =>
949
+ useResolvedScope({
950
+ supabase: mockSupabase,
951
+ selectedOrganisationId,
952
+ selectedEventId,
953
+ }),
954
+ {
955
+ initialProps: {
956
+ selectedOrganisationId: 'org-123',
957
+ selectedEventId: 'event-123',
958
+ },
959
+ }
960
+ );
961
+
962
+ await waitFor(
963
+ () => {
964
+ expect(result.current.isLoading).toBe(false);
965
+ },
966
+ { timeout: 2000 }
967
+ );
968
+
969
+ // Force rerender to pick up ref update
970
+ rerender({
971
+ supabase: mockSupabase,
972
+ selectedOrganisationId: 'org-123',
973
+ selectedEventId: 'event-123',
974
+ });
975
+
976
+ await waitFor(
977
+ () => {
978
+ expect(result.current.resolvedScope).not.toBeNull();
979
+ },
980
+ { timeout: 2000, interval: 10 }
981
+ );
982
+
983
+ // Mock slow refetch
984
+ sharedMockQuery.single.mockImplementation(
985
+ () => new Promise(() => {}) // Never resolves
986
+ );
987
+
988
+ rerender({
989
+ selectedOrganisationId: 'org-456',
990
+ selectedEventId: 'event-123',
991
+ });
992
+
993
+ // Should show loading during refetch
994
+ expect(result.current.isLoading).toBe(true);
995
+ });
996
+ });
997
+
998
+ describe('Cleanup', () => {
999
+ it('cancels in-flight requests when dependencies change', async () => {
1000
+ let resolveCount = 0;
1001
+
1002
+ sharedMockQuery.single.mockImplementation(() => {
1003
+ resolveCount++;
1004
+ return new Promise((resolve) => {
1005
+ setTimeout(() => {
1006
+ resolve({
1007
+ data: { id: 'app-123', name: 'test-app', is_active: true },
1008
+ error: null,
1009
+ });
1010
+ }, 100);
1011
+ });
1012
+ });
1013
+
1014
+ const { result, rerender } = renderHook(
1015
+ ({ selectedOrganisationId, selectedEventId }) =>
1016
+ useResolvedScope({
1017
+ supabase: mockSupabase,
1018
+ selectedOrganisationId,
1019
+ selectedEventId,
1020
+ }),
1021
+ {
1022
+ initialProps: {
1023
+ selectedOrganisationId: 'org-123',
1024
+ selectedEventId: 'event-123',
1025
+ },
1026
+ }
1027
+ );
1028
+
1029
+ // Rapidly change dependencies
1030
+ rerender({
1031
+ selectedOrganisationId: 'org-456',
1032
+ selectedEventId: 'event-123',
1033
+ });
1034
+
1035
+ rerender({
1036
+ selectedOrganisationId: 'org-789',
1037
+ selectedEventId: 'event-123',
1038
+ });
1039
+
1040
+ await waitFor(
1041
+ () => {
1042
+ expect(result.current.isLoading).toBe(false);
1043
+ },
1044
+ { timeout: 2000 }
1045
+ );
1046
+
1047
+ // Force rerender to pick up ref update
1048
+ rerender({
1049
+ selectedOrganisationId: 'org-789',
1050
+ selectedEventId: 'event-123',
1051
+ });
1052
+
1053
+ await waitFor(
1054
+ () => {
1055
+ expect(result.current.resolvedScope).not.toBeNull();
1056
+ expect(result.current.resolvedScope?.organisationId).toBe('org-789');
1057
+ },
1058
+ { timeout: 2000, interval: 10 }
1059
+ );
1060
+ });
1061
+ });
1062
+ });
1063
+