@jmruthers/pace-core 0.5.1 → 0.5.4

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 (210) hide show
  1. package/dist/{DataTable-GX3XERFJ.js → DataTable-ZQDRE46Q.js} +7 -6
  2. package/dist/{PublicLoadingSpinner-DztrzuJr.d.ts → PublicLoadingSpinner-Bq_-BeK-.d.ts} +1 -1
  3. package/dist/RBACProvider-BO4ilsQB.d.ts +63 -0
  4. package/dist/{UnifiedAuthProvider-w66zSCUf.d.ts → UnifiedAuthProvider-DGQsy-vY.d.ts} +2 -59
  5. package/dist/{api-ETQ6YJ3C.js → api-H5A3H4IR.js} +2 -2
  6. package/dist/{chunk-T3XIA4AJ.js → chunk-5H3C2SWM.js} +14 -16
  7. package/dist/chunk-5H3C2SWM.js.map +1 -0
  8. package/dist/chunk-5SIXIV7R.js +1925 -0
  9. package/dist/chunk-5SIXIV7R.js.map +1 -0
  10. package/dist/chunk-GNTALZV3.js +17 -0
  11. package/dist/chunk-GNTALZV3.js.map +1 -0
  12. package/dist/{chunk-C5G2A4PO.js → chunk-GWSBHC4J.js} +6 -6
  13. package/dist/{chunk-XJK2J4N6.js → chunk-HD7PYDUV.js} +4 -6
  14. package/dist/{chunk-XJK2J4N6.js.map → chunk-HD7PYDUV.js.map} +1 -1
  15. package/dist/{chunk-TGDCLPP2.js → chunk-HXX35Q2M.js} +6 -21
  16. package/dist/chunk-HXX35Q2M.js.map +1 -0
  17. package/dist/{chunk-5EL3KHOQ.js → chunk-K6B7BLSE.js} +2 -2
  18. package/dist/{chunk-GSNM5D6H.js → chunk-M4RW7PIP.js} +4 -4
  19. package/dist/{chunk-U6JDHVC2.js → chunk-PVMYVQSM.js} +6 -8
  20. package/dist/{chunk-U6JDHVC2.js.map → chunk-PVMYVQSM.js.map} +1 -1
  21. package/dist/{chunk-6CR3MRZN.js → chunk-QKHFMQ5R.js} +372 -11
  22. package/dist/{chunk-6CR3MRZN.js.map → chunk-QKHFMQ5R.js.map} +1 -1
  23. package/dist/chunk-QVYBYGT2.js +428 -0
  24. package/dist/chunk-QVYBYGT2.js.map +1 -0
  25. package/dist/{chunk-OEGRKULD.js → chunk-WJARTBCT.js} +56 -1
  26. package/dist/chunk-WJARTBCT.js.map +1 -0
  27. package/dist/components.d.ts +4 -3
  28. package/dist/components.js +16 -162
  29. package/dist/components.js.map +1 -1
  30. package/dist/hooks.d.ts +2 -2
  31. package/dist/hooks.js +7 -9
  32. package/dist/hooks.js.map +1 -1
  33. package/dist/index.d.ts +8 -6
  34. package/dist/index.js +152 -17
  35. package/dist/index.js.map +1 -1
  36. package/dist/providers.d.ts +3 -2
  37. package/dist/providers.js +6 -12
  38. package/dist/rbac/index.d.ts +167 -98
  39. package/dist/rbac/index.js +48 -1881
  40. package/dist/rbac/index.js.map +1 -1
  41. package/dist/styles/core.css +0 -55
  42. package/dist/types.d.ts +2 -2
  43. package/dist/{unified-CM7T0aTK.d.ts → unified-CMPjE_fv.d.ts} +1 -1
  44. package/dist/{usePublicRouteParams-B6i0KtXW.d.ts → usePublicRouteParams-B2OcAsur.d.ts} +1 -1
  45. package/dist/utils.js +12 -14
  46. package/dist/utils.js.map +1 -1
  47. package/docs/api/classes/ErrorBoundary.md +1 -1
  48. package/docs/api/classes/InvalidScopeError.md +73 -0
  49. package/docs/api/classes/MissingUserContextError.md +66 -0
  50. package/docs/api/classes/OrganisationContextRequiredError.md +66 -0
  51. package/docs/api/classes/PermissionDeniedError.md +73 -0
  52. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  53. package/docs/api/classes/RBACAuditManager.md +270 -0
  54. package/docs/api/classes/RBACCache.md +284 -0
  55. package/docs/api/classes/RBACEngine.md +141 -0
  56. package/docs/api/classes/RBACError.md +76 -0
  57. package/docs/api/classes/RBACNotInitializedError.md +66 -0
  58. package/docs/api/classes/SecureSupabaseClient.md +135 -0
  59. package/docs/api/interfaces/AggregateConfig.md +1 -1
  60. package/docs/api/interfaces/ButtonProps.md +1 -1
  61. package/docs/api/interfaces/CardProps.md +1 -1
  62. package/docs/api/interfaces/ColorPalette.md +1 -1
  63. package/docs/api/interfaces/ColorShade.md +1 -1
  64. package/docs/api/interfaces/DataAccessRecord.md +96 -0
  65. package/docs/api/interfaces/DataTableAction.md +1 -1
  66. package/docs/api/interfaces/DataTableColumn.md +1 -1
  67. package/docs/api/interfaces/DataTableProps.md +1 -1
  68. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  69. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  70. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +235 -0
  71. package/docs/api/interfaces/EventContextType.md +1 -1
  72. package/docs/api/interfaces/EventLogoProps.md +1 -1
  73. package/docs/api/interfaces/EventProviderProps.md +1 -1
  74. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  75. package/docs/api/interfaces/FileUploadProps.md +1 -1
  76. package/docs/api/interfaces/FooterProps.md +1 -1
  77. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  78. package/docs/api/interfaces/InputProps.md +1 -1
  79. package/docs/api/interfaces/LabelProps.md +1 -1
  80. package/docs/api/interfaces/LoginFormProps.md +1 -1
  81. package/docs/api/interfaces/NavigationAccessRecord.md +107 -0
  82. package/docs/api/interfaces/NavigationContextType.md +164 -0
  83. package/docs/api/interfaces/NavigationGuardProps.md +139 -0
  84. package/docs/api/interfaces/NavigationItem.md +1 -1
  85. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  86. package/docs/api/interfaces/NavigationProviderProps.md +117 -0
  87. package/docs/api/interfaces/Organisation.md +1 -1
  88. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  89. package/docs/api/interfaces/OrganisationMembership.md +2 -2
  90. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  91. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  92. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  93. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  94. package/docs/api/interfaces/PageAccessRecord.md +85 -0
  95. package/docs/api/interfaces/PagePermissionContextType.md +140 -0
  96. package/docs/api/interfaces/PagePermissionGuardProps.md +153 -0
  97. package/docs/api/interfaces/PagePermissionProviderProps.md +119 -0
  98. package/docs/api/interfaces/PaletteData.md +1 -1
  99. package/docs/api/interfaces/PermissionEnforcerProps.md +153 -0
  100. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  101. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  102. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  103. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  104. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  105. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  106. package/docs/api/interfaces/RBACConfig.md +99 -0
  107. package/docs/api/interfaces/RBACContextType.md +474 -0
  108. package/docs/api/interfaces/RBACLogger.md +112 -0
  109. package/docs/api/interfaces/RBACProviderProps.md +107 -0
  110. package/docs/api/interfaces/RoleBasedRouterContextType.md +151 -0
  111. package/docs/api/interfaces/RoleBasedRouterProps.md +156 -0
  112. package/docs/api/interfaces/RouteAccessRecord.md +107 -0
  113. package/docs/api/interfaces/RouteConfig.md +121 -0
  114. package/docs/api/interfaces/SecureDataContextType.md +168 -0
  115. package/docs/api/interfaces/SecureDataProviderProps.md +132 -0
  116. package/docs/api/interfaces/StorageConfig.md +1 -1
  117. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  118. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  119. package/docs/api/interfaces/StorageListOptions.md +1 -1
  120. package/docs/api/interfaces/StorageListResult.md +1 -1
  121. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  122. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  123. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  124. package/docs/api/interfaces/StyleImport.md +1 -1
  125. package/docs/api/interfaces/ToastActionElement.md +1 -1
  126. package/docs/api/interfaces/ToastProps.md +1 -1
  127. package/docs/api/interfaces/UnifiedAuthContextType.md +85 -85
  128. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  129. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  130. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  131. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  132. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  133. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  134. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  135. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  136. package/docs/api/interfaces/UserEventAccess.md +11 -11
  137. package/docs/api/interfaces/UserMenuProps.md +1 -1
  138. package/docs/api/interfaces/UserProfile.md +1 -1
  139. package/docs/api/modules.md +2244 -3
  140. package/docs/migration-guide.md +43 -18
  141. package/docs/styles/README.md +187 -98
  142. package/docs/usage.md +32 -7
  143. package/package.json +2 -2
  144. package/src/components/Footer/Footer.test.tsx +482 -0
  145. package/src/components/Form/Form.test.tsx +1158 -0
  146. package/src/components/Header/Header.test.tsx +582 -0
  147. package/src/components/Header/Header.tsx +1 -1
  148. package/src/components/InactivityWarningModal/InactivityWarningModal.test.tsx +489 -0
  149. package/src/components/Input/Input.test.tsx +466 -0
  150. package/src/components/LoadingSpinner/LoadingSpinner.test.tsx +450 -0
  151. package/src/components/LoginForm/LoginForm.test.tsx +816 -0
  152. package/src/components/NavigationMenu/NavigationMenu.test.tsx +883 -0
  153. package/src/components/OrganisationSelector/OrganisationSelector.test.tsx +748 -0
  154. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +891 -0
  155. package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +475 -0
  156. package/src/components/PasswordReset/PasswordChangeForm.test.tsx +621 -0
  157. package/src/components/PasswordReset/PasswordResetForm.test.tsx +605 -0
  158. package/src/components/Select/Select.test.tsx +948 -0
  159. package/src/components/SuperAdminGuard.tsx +1 -1
  160. package/src/components/Toast/Toast.test.tsx +586 -0
  161. package/src/components/Tooltip/Tooltip.test.tsx +852 -0
  162. package/src/components/UserMenu/UserMenu.test.tsx +702 -0
  163. package/src/components/UserMenu/UserMenu.tsx +2 -2
  164. package/src/hooks/useDebounce.test.ts +375 -0
  165. package/src/hooks/useOrganisationPermissions.test.ts +528 -0
  166. package/src/hooks/useOrganisationSecurity.test.ts +734 -0
  167. package/src/hooks/usePermissionCache.test.ts +542 -0
  168. package/src/hooks/usePermissionCache.ts +1 -1
  169. package/src/index.ts +2 -3
  170. package/src/providers/UnifiedAuthProvider.tsx +2 -2
  171. package/src/providers/index.ts +3 -1
  172. package/src/rbac/__tests__/integration.test.tsx +218 -0
  173. package/src/rbac/api.test.ts +952 -0
  174. package/src/rbac/components/__tests__/NavigationGuard.test.tsx +843 -0
  175. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1007 -0
  176. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +806 -0
  177. package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +741 -0
  178. package/src/rbac/hooks/index.ts +21 -0
  179. package/src/rbac/hooks/useCan.test.ts +461 -0
  180. package/src/rbac/hooks/usePermissions.test.ts +364 -0
  181. package/src/rbac/hooks/usePermissions.ts +567 -0
  182. package/src/rbac/hooks/useRBAC.simple.test.ts +90 -0
  183. package/src/rbac/hooks/useRBAC.test.ts +551 -0
  184. package/src/{hooks → rbac/hooks}/useRBAC.ts +7 -7
  185. package/src/rbac/index.ts +5 -10
  186. package/src/{providers → rbac/providers}/RBACProvider.tsx +6 -6
  187. package/src/rbac/providers/__tests__/RBACProvider.test.tsx +687 -0
  188. package/src/rbac/providers/index.ts +11 -0
  189. package/src/styles/core.css +0 -55
  190. package/src/utils/formatDate.test.ts +241 -0
  191. package/dist/chunk-AUE24LVR.js +0 -268
  192. package/dist/chunk-AUE24LVR.js.map +0 -1
  193. package/dist/chunk-COBPIXXQ.js +0 -379
  194. package/dist/chunk-COBPIXXQ.js.map +0 -1
  195. package/dist/chunk-OEGRKULD.js.map +0 -1
  196. package/dist/chunk-OYRY44Q2.js +0 -62
  197. package/dist/chunk-OYRY44Q2.js.map +0 -1
  198. package/dist/chunk-T3XIA4AJ.js.map +0 -1
  199. package/dist/chunk-TGDCLPP2.js.map +0 -1
  200. package/src/components/RBAC/PagePermissionGuard.tsx +0 -287
  201. package/src/components/RBAC/RBACGuard.tsx +0 -143
  202. package/src/components/RBAC/RBACProvider.tsx +0 -186
  203. package/src/components/RBAC/RoleBasedContent.tsx +0 -129
  204. package/src/components/RBAC/index.ts +0 -23
  205. package/src/rbac/hooks.ts +0 -570
  206. /package/dist/{DataTable-GX3XERFJ.js.map → DataTable-ZQDRE46Q.js.map} +0 -0
  207. /package/dist/{api-ETQ6YJ3C.js.map → api-H5A3H4IR.js.map} +0 -0
  208. /package/dist/{chunk-C5G2A4PO.js.map → chunk-GWSBHC4J.js.map} +0 -0
  209. /package/dist/{chunk-5EL3KHOQ.js.map → chunk-K6B7BLSE.js.map} +0 -0
  210. /package/dist/{chunk-GSNM5D6H.js.map → chunk-M4RW7PIP.js.map} +0 -0
@@ -0,0 +1,891 @@
1
+ /**
2
+ * @file PaceAppLayout Component Tests
3
+ * @description Comprehensive test suite for PaceAppLayout component
4
+ * @package @jmruthers/pace-core
5
+ */
6
+
7
+ import React from 'react';
8
+ import { screen, waitFor } from '@testing-library/react';
9
+ import userEvent from '@testing-library/user-event';
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { MemoryRouter } from 'react-router-dom';
12
+ import { PaceAppLayout } from './PaceAppLayout';
13
+ import { renderWithProviders } from '../../__tests__/helpers/test-utils';
14
+ import { useUnifiedAuth } from '../../providers/UnifiedAuthProvider';
15
+
16
+ // Mock React Router hooks
17
+ const mockNavigate = vi.fn();
18
+ const mockLocation = { pathname: '/dashboard' };
19
+
20
+ vi.mock('react-router-dom', async () => {
21
+ const actual = await vi.importActual('react-router-dom');
22
+ return {
23
+ ...actual,
24
+ useNavigate: () => mockNavigate,
25
+ useLocation: () => mockLocation,
26
+ Outlet: () => <div data-testid="outlet">Page Content</div>,
27
+ };
28
+ });
29
+
30
+ // Mock UnifiedAuth hook
31
+ const mockUser = {
32
+ id: 'user-123',
33
+ email: 'test@example.com',
34
+ user_metadata: {
35
+ organisationId: 'org-123',
36
+ eventId: 'event-123',
37
+ appId: 'app-123',
38
+ is_admin: false,
39
+ },
40
+ app_metadata: {
41
+ organisationId: 'org-123',
42
+ eventId: 'event-123',
43
+ appId: 'app-123',
44
+ },
45
+ };
46
+
47
+ const mockUnifiedAuth = {
48
+ user: mockUser,
49
+ signOut: vi.fn().mockResolvedValue(undefined),
50
+ updatePassword: vi.fn().mockResolvedValue({ error: null }),
51
+ };
52
+
53
+ vi.mock('../../providers/UnifiedAuthProvider', () => ({
54
+ useUnifiedAuth: vi.fn(() => mockUnifiedAuth),
55
+ }));
56
+
57
+ // Mock RBAC functions
58
+ vi.mock('../../rbac/api', () => ({
59
+ isPermitted: vi.fn().mockResolvedValue(true),
60
+ isSuperAdmin: vi.fn().mockResolvedValue(false),
61
+ setupRBAC: vi.fn(),
62
+ }));
63
+
64
+ // Mock Header component
65
+ vi.mock('../Header', () => ({
66
+ Header: ({
67
+ logo,
68
+ logoUrl,
69
+ logoAlt,
70
+ navItems,
71
+ actions,
72
+ userMenu,
73
+ user,
74
+ onSignOut,
75
+ onChangePassword,
76
+ currentPath,
77
+ onNavigate,
78
+ showEventSelector,
79
+ showUserMenu,
80
+ className
81
+ }: any) => (
82
+ <header
83
+ data-testid="header"
84
+ className={className}
85
+ data-show-event-selector={showEventSelector !== false ? 'true' : 'false'}
86
+ data-show-user-menu={showUserMenu !== false ? 'true' : 'false'}
87
+ >
88
+ <div data-testid="header-logo" data-logo-url={logoUrl} data-logo-alt={logoAlt}>
89
+ {logo || 'Default Logo'}
90
+ </div>
91
+ <nav data-testid="header-nav">
92
+ {navItems?.map((item: any) => (
93
+ <button
94
+ key={item.id}
95
+ data-testid={`nav-item-${item.id}`}
96
+ onClick={() => onNavigate?.(item)}
97
+ >
98
+ {item.label}
99
+ </button>
100
+ ))}
101
+ </nav>
102
+ <div data-testid="header-actions">{actions}</div>
103
+ <div data-testid="header-user-menu">
104
+ {userMenu || (
105
+ <div>
106
+ <span data-testid="user-email">{user?.email}</span>
107
+ <button data-testid="sign-out-btn" onClick={onSignOut}>
108
+ Sign Out
109
+ </button>
110
+ <button data-testid="change-password-btn" onClick={() => onChangePassword?.('newpassword')}>
111
+ Change Password
112
+ </button>
113
+ </div>
114
+ )}
115
+ </div>
116
+ </header>
117
+ ),
118
+ }));
119
+
120
+ // Mock Footer component
121
+ vi.mock('../Footer', () => ({
122
+ Footer: () => (
123
+ <footer data-testid="footer">
124
+ <div>Footer Content</div>
125
+ </footer>
126
+ ),
127
+ }));
128
+
129
+ // Mock Button component
130
+ vi.mock('../Button', () => ({
131
+ Button: ({ children, onClick, ...props }: any) => (
132
+ <button onClick={onClick} {...props}>
133
+ {children}
134
+ </button>
135
+ ),
136
+ }));
137
+
138
+ // Test wrapper with router
139
+ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
140
+ <MemoryRouter initialEntries={['/dashboard']}>
141
+ {children}
142
+ </MemoryRouter>
143
+ );
144
+
145
+ describe('PaceAppLayout Component', () => {
146
+ const defaultProps = {
147
+ appName: 'Test App',
148
+ };
149
+
150
+ beforeEach(async () => {
151
+ vi.clearAllMocks();
152
+ // Reset location mock
153
+ mockLocation.pathname = '/dashboard';
154
+ // Reset RBAC mocks
155
+ const { isPermitted, isSuperAdmin } = await import('../../rbac/api');
156
+ vi.mocked(isPermitted).mockReset();
157
+ vi.mocked(isPermitted).mockResolvedValue(true);
158
+ vi.mocked(isSuperAdmin).mockReset();
159
+ vi.mocked(isSuperAdmin).mockResolvedValue(false);
160
+ });
161
+
162
+ describe('Basic Rendering', () => {
163
+ it('renders with default props', () => {
164
+ renderWithProviders(
165
+ <TestWrapper>
166
+ <PaceAppLayout {...defaultProps} />
167
+ </TestWrapper>
168
+ );
169
+
170
+ expect(screen.getByTestId('header')).toBeInTheDocument();
171
+ expect(screen.getByTestId('footer')).toBeInTheDocument();
172
+ expect(screen.getByTestId('outlet')).toBeInTheDocument();
173
+ });
174
+
175
+ it('renders with custom app name', () => {
176
+ renderWithProviders(
177
+ <TestWrapper>
178
+ <PaceAppLayout {...defaultProps} appName="Custom App" />
179
+ </TestWrapper>
180
+ );
181
+
182
+ expect(screen.getByTestId('header-logo')).toHaveAttribute('data-logo-alt', 'Custom App Logo');
183
+ });
184
+
185
+ it('renders with custom navigation items', () => {
186
+ const customNavItems = [
187
+ { id: 'home', label: 'Home', href: '/', icon: 'Home' },
188
+ { id: 'about', label: 'About', href: '/about', icon: 'Info' },
189
+ ];
190
+
191
+ renderWithProviders(
192
+ <TestWrapper>
193
+ <PaceAppLayout {...defaultProps} navItems={customNavItems} />
194
+ </TestWrapper>
195
+ );
196
+
197
+ expect(screen.getByTestId('nav-item-home')).toBeInTheDocument();
198
+ expect(screen.getByTestId('nav-item-about')).toBeInTheDocument();
199
+ });
200
+
201
+ it('renders with default navigation items when none provided', () => {
202
+ renderWithProviders(
203
+ <TestWrapper>
204
+ <PaceAppLayout {...defaultProps} />
205
+ </TestWrapper>
206
+ );
207
+
208
+ expect(screen.getByTestId('nav-item-home')).toBeInTheDocument();
209
+ expect(screen.getByTestId('nav-item-dashboard')).toBeInTheDocument();
210
+ expect(screen.getByTestId('nav-item-settings')).toBeInTheDocument();
211
+ });
212
+
213
+ it('renders with custom header actions', () => {
214
+ const headerActions = <div data-testid="custom-actions">Custom Actions</div>;
215
+
216
+ renderWithProviders(
217
+ <TestWrapper>
218
+ <PaceAppLayout {...defaultProps} headerActions={headerActions} />
219
+ </TestWrapper>
220
+ );
221
+
222
+ expect(screen.getByTestId('custom-actions')).toBeInTheDocument();
223
+ });
224
+
225
+ it('renders with custom logo', () => {
226
+ const customLogo = <div data-testid="custom-logo">Custom Logo</div>;
227
+
228
+ renderWithProviders(
229
+ <TestWrapper>
230
+ <PaceAppLayout {...defaultProps} customLogo={customLogo} />
231
+ </TestWrapper>
232
+ );
233
+
234
+ expect(screen.getByTestId('custom-logo')).toBeInTheDocument();
235
+ });
236
+
237
+ it('renders with custom user menu', () => {
238
+ const customUserMenu = <div data-testid="custom-user-menu">Custom User Menu</div>;
239
+
240
+ renderWithProviders(
241
+ <TestWrapper>
242
+ <PaceAppLayout {...defaultProps} customUserMenu={customUserMenu} />
243
+ </TestWrapper>
244
+ );
245
+
246
+ expect(screen.getByTestId('custom-user-menu')).toBeInTheDocument();
247
+ });
248
+
249
+ it('renders with custom header className', () => {
250
+ renderWithProviders(
251
+ <TestWrapper>
252
+ <PaceAppLayout {...defaultProps} headerClassName="custom-header" />
253
+ </TestWrapper>
254
+ );
255
+
256
+ expect(screen.getByTestId('header')).toHaveClass('custom-header');
257
+ });
258
+ });
259
+
260
+ describe('Event Selector Control', () => {
261
+ it('shows event selector by default', () => {
262
+ renderWithProviders(
263
+ <TestWrapper>
264
+ <PaceAppLayout {...defaultProps} />
265
+ </TestWrapper>
266
+ );
267
+
268
+ expect(screen.getByTestId('header')).toHaveAttribute('data-show-event-selector', 'true');
269
+ });
270
+
271
+ it('hides event selector when showEventSelector is false', () => {
272
+ renderWithProviders(
273
+ <TestWrapper>
274
+ <PaceAppLayout {...defaultProps} showEventSelector={false} />
275
+ </TestWrapper>
276
+ );
277
+
278
+ expect(screen.getByTestId('header')).toHaveAttribute('data-show-event-selector', 'false');
279
+ });
280
+ });
281
+
282
+ describe('User Menu Control', () => {
283
+ it('shows user menu by default', () => {
284
+ renderWithProviders(
285
+ <TestWrapper>
286
+ <PaceAppLayout {...defaultProps} />
287
+ </TestWrapper>
288
+ );
289
+
290
+ expect(screen.getByTestId('header')).toHaveAttribute('data-show-user-menu', 'true');
291
+ });
292
+
293
+ it('hides user menu when showUserMenu is false', () => {
294
+ renderWithProviders(
295
+ <TestWrapper>
296
+ <PaceAppLayout {...defaultProps} showUserMenu={false} />
297
+ </TestWrapper>
298
+ );
299
+
300
+ expect(screen.getByTestId('header')).toHaveAttribute('data-show-user-menu', 'false');
301
+ });
302
+ });
303
+
304
+ describe('User Interactions', () => {
305
+ it('handles sign out', async () => {
306
+ const user = userEvent.setup();
307
+ renderWithProviders(
308
+ <TestWrapper>
309
+ <PaceAppLayout {...defaultProps} />
310
+ </TestWrapper>
311
+ );
312
+
313
+ await user.click(screen.getByTestId('sign-out-btn'));
314
+ expect(mockUnifiedAuth.signOut).toHaveBeenCalledTimes(1);
315
+ });
316
+
317
+ it('handles password change', async () => {
318
+ const user = userEvent.setup();
319
+ renderWithProviders(
320
+ <TestWrapper>
321
+ <PaceAppLayout {...defaultProps} />
322
+ </TestWrapper>
323
+ );
324
+
325
+ await user.click(screen.getByTestId('change-password-btn'));
326
+ expect(mockUnifiedAuth.updatePassword).toHaveBeenCalledWith('newpassword');
327
+ });
328
+
329
+ it('handles navigation clicks', async () => {
330
+ const user = userEvent.setup();
331
+ const customNavItems = [
332
+ { id: 'home', label: 'Home', href: '/', icon: 'Home' },
333
+ { id: 'about', label: 'About', href: '/about', icon: 'Info' },
334
+ ];
335
+
336
+ renderWithProviders(
337
+ <TestWrapper>
338
+ <PaceAppLayout {...defaultProps} navItems={customNavItems} />
339
+ </TestWrapper>
340
+ );
341
+
342
+ await user.click(screen.getByTestId('nav-item-home'));
343
+ expect(mockNavigate).toHaveBeenCalledWith('/');
344
+
345
+ await user.click(screen.getByTestId('nav-item-about'));
346
+ expect(mockNavigate).toHaveBeenCalledWith('/about');
347
+ });
348
+ });
349
+
350
+ describe('Permission Enforcement', () => {
351
+ it('renders normally when enforcePermissions is false', () => {
352
+ renderWithProviders(
353
+ <TestWrapper>
354
+ <PaceAppLayout {...defaultProps} enforcePermissions={false} />
355
+ </TestWrapper>
356
+ );
357
+
358
+ expect(screen.getByTestId('header')).toBeInTheDocument();
359
+ expect(screen.getByTestId('outlet')).toBeInTheDocument();
360
+ });
361
+
362
+ it('shows loading state when checking permissions', async () => {
363
+ const { isPermitted } = await import('../../rbac/api');
364
+ vi.mocked(isPermitted).mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(true), 100)));
365
+
366
+ renderWithProviders(
367
+ <TestWrapper>
368
+ <PaceAppLayout {...defaultProps} enforcePermissions={true} />
369
+ </TestWrapper>
370
+ );
371
+
372
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
373
+ expect(screen.queryByTestId('header')).not.toBeInTheDocument();
374
+ });
375
+
376
+ it('shows permission error when check fails', async () => {
377
+ const { isPermitted } = await import('../../rbac/api');
378
+ vi.mocked(isPermitted).mockRejectedValue(new Error('Permission check failed'));
379
+
380
+ renderWithProviders(
381
+ <TestWrapper>
382
+ <PaceAppLayout {...defaultProps} enforcePermissions={true} />
383
+ </TestWrapper>
384
+ );
385
+
386
+ await waitFor(() => {
387
+ expect(screen.getByText('Permission Error')).toBeInTheDocument();
388
+ expect(screen.getByText('Permission check failed')).toBeInTheDocument();
389
+ });
390
+ });
391
+
392
+ it('shows access denied when user lacks permission', async () => {
393
+ const { isPermitted } = await import('../../rbac/api');
394
+ vi.mocked(isPermitted).mockResolvedValue(false);
395
+
396
+ renderWithProviders(
397
+ <TestWrapper>
398
+ <PaceAppLayout {...defaultProps} enforcePermissions={true} />
399
+ </TestWrapper>
400
+ );
401
+
402
+ await waitFor(() => {
403
+ expect(screen.getByText('Access Denied')).toBeInTheDocument();
404
+ expect(screen.getByText("You don't have permission to access this page.")).toBeInTheDocument();
405
+ });
406
+ });
407
+
408
+ it('shows custom permission fallback when provided', async () => {
409
+ const { isPermitted } = await import('../../rbac/api');
410
+ vi.mocked(isPermitted).mockResolvedValue(false);
411
+
412
+ const customFallback = <div data-testid="custom-fallback">Custom Access Denied</div>;
413
+
414
+ renderWithProviders(
415
+ <TestWrapper>
416
+ <PaceAppLayout
417
+ {...defaultProps}
418
+ enforcePermissions={true}
419
+ permissionFallback={customFallback}
420
+ />
421
+ </TestWrapper>
422
+ );
423
+
424
+ await waitFor(() => {
425
+ expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
426
+ }, { timeout: 3000 });
427
+ });
428
+
429
+ it('shows page permission fallback when enforcePagePermissions is true', async () => {
430
+ const { isPermitted } = await import('../../rbac/api');
431
+ vi.mocked(isPermitted).mockResolvedValue(false);
432
+
433
+ const pageFallback = <div data-testid="page-fallback">Page Access Denied</div>;
434
+
435
+ renderWithProviders(
436
+ <TestWrapper>
437
+ <PaceAppLayout
438
+ {...defaultProps}
439
+ enforcePermissions={true}
440
+ enforcePagePermissions={true}
441
+ pagePermissionFallback={pageFallback}
442
+ />
443
+ </TestWrapper>
444
+ );
445
+
446
+ await waitFor(() => {
447
+ expect(screen.getByTestId('page-fallback')).toBeInTheDocument();
448
+ }, { timeout: 3000 });
449
+ });
450
+ });
451
+
452
+ describe('Navigation Filtering', () => {
453
+ it('filters navigation items based on permissions', async () => {
454
+ const { isPermitted } = await import('../../rbac/api');
455
+ // Mock to return true for all items initially to verify rendering works
456
+ vi.mocked(isPermitted).mockResolvedValue(true);
457
+
458
+ const customNavItems = [
459
+ { id: 'home', label: 'Home', href: '/', icon: 'Home' },
460
+ { id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' },
461
+ { id: 'settings', label: 'Settings', href: '/settings', icon: 'Settings' },
462
+ ];
463
+
464
+ renderWithProviders(
465
+ <TestWrapper>
466
+ <PaceAppLayout
467
+ {...defaultProps}
468
+ navItems={customNavItems}
469
+ enforcePermissions={true}
470
+ filterNavigationByPermissions={true}
471
+ routePermissions={{
472
+ '/': 'read',
473
+ '/dashboard': 'read',
474
+ '/settings': 'read',
475
+ }}
476
+ />
477
+ </TestWrapper>
478
+ );
479
+
480
+ // Wait for the component to render initially
481
+ await waitFor(() => {
482
+ expect(screen.getByTestId('header')).toBeInTheDocument();
483
+ });
484
+
485
+ // Verify that navigation items are rendered
486
+ await waitFor(() => {
487
+ expect(screen.getByTestId('nav-item-home')).toBeInTheDocument();
488
+ expect(screen.getByTestId('nav-item-dashboard')).toBeInTheDocument();
489
+ expect(screen.getByTestId('nav-item-settings')).toBeInTheDocument();
490
+ }, { timeout: 5000 });
491
+ });
492
+
493
+ it('shows all navigation items when filterNavigationByPermissions is false', () => {
494
+ const customNavItems = [
495
+ { id: 'home', label: 'Home', href: '/', icon: 'Home' },
496
+ { id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' },
497
+ ];
498
+
499
+ renderWithProviders(
500
+ <TestWrapper>
501
+ <PaceAppLayout
502
+ {...defaultProps}
503
+ navItems={customNavItems}
504
+ enforcePermissions={false}
505
+ filterNavigationByPermissions={false}
506
+ />
507
+ </TestWrapper>
508
+ );
509
+
510
+ expect(screen.getByTestId('nav-item-home')).toBeInTheDocument();
511
+ expect(screen.getByTestId('nav-item-dashboard')).toBeInTheDocument();
512
+ });
513
+ });
514
+
515
+ describe('Route-Specific Permissions', () => {
516
+ it('uses route-specific permissions when provided', async () => {
517
+ const { isPermitted } = await import('../../rbac/api');
518
+ vi.mocked(isPermitted).mockResolvedValue(true);
519
+
520
+ renderWithProviders(
521
+ <TestWrapper>
522
+ <PaceAppLayout
523
+ {...defaultProps}
524
+ enforcePermissions={true}
525
+ routePermissions={{
526
+ '/dashboard': 'update',
527
+ }}
528
+ pageIdMapping={{
529
+ '/dashboard': 'dashboard-page',
530
+ }}
531
+ />
532
+ </TestWrapper>
533
+ );
534
+
535
+ await waitFor(() => {
536
+ expect(vi.mocked(isPermitted)).toHaveBeenCalledWith({
537
+ userId: 'user-123',
538
+ scope: {
539
+ organisationId: 'org-123',
540
+ eventId: 'event-123',
541
+ appId: 'app-123',
542
+ },
543
+ permission: 'update',
544
+ pageId: 'dashboard-page',
545
+ });
546
+ }, { timeout: 3000 });
547
+ });
548
+
549
+ it('uses default permission when route not in routePermissions', async () => {
550
+ const { isPermitted } = await import('../../rbac/api');
551
+ vi.mocked(isPermitted).mockResolvedValue(true);
552
+
553
+ renderWithProviders(
554
+ <TestWrapper>
555
+ <PaceAppLayout
556
+ {...defaultProps}
557
+ enforcePermissions={true}
558
+ defaultPermission="create"
559
+ routePermissions={{
560
+ '/other': 'read',
561
+ }}
562
+ />
563
+ </TestWrapper>
564
+ );
565
+
566
+ await waitFor(() => {
567
+ expect(vi.mocked(isPermitted)).toHaveBeenCalledWith({
568
+ userId: 'user-123',
569
+ scope: {
570
+ organisationId: 'org-123',
571
+ eventId: 'event-123',
572
+ appId: 'app-123',
573
+ },
574
+ permission: 'create',
575
+ pageId: 'dashboard',
576
+ });
577
+ }, { timeout: 3000 });
578
+ });
579
+ });
580
+
581
+ describe('Super Admin Bypass', () => {
582
+ it('bypasses permission checks for super admin', async () => {
583
+ const { isSuperAdmin } = await import('../../rbac/api');
584
+ vi.mocked(isSuperAdmin).mockResolvedValue({ isSuperAdmin: true });
585
+
586
+ renderWithProviders(
587
+ <TestWrapper>
588
+ <PaceAppLayout
589
+ {...defaultProps}
590
+ enforcePermissions={true}
591
+ />
592
+ </TestWrapper>
593
+ );
594
+
595
+ await waitFor(() => {
596
+ expect(screen.getByTestId('header')).toBeInTheDocument();
597
+ expect(screen.getByTestId('outlet')).toBeInTheDocument();
598
+ });
599
+ });
600
+ });
601
+
602
+ describe('Callbacks and Event Handling', () => {
603
+ it('calls onPageAccessDenied when access is denied', async () => {
604
+ const { isPermitted } = await import('../../rbac/api');
605
+ vi.mocked(isPermitted).mockResolvedValue(false);
606
+
607
+ const onPageAccessDenied = vi.fn();
608
+
609
+ renderWithProviders(
610
+ <TestWrapper>
611
+ <PaceAppLayout
612
+ {...defaultProps}
613
+ enforcePermissions={true}
614
+ onPageAccessDenied={onPageAccessDenied}
615
+ />
616
+ </TestWrapper>
617
+ );
618
+
619
+ await waitFor(() => {
620
+ expect(onPageAccessDenied).toHaveBeenCalledWith('dashboard', 'read');
621
+ }, { timeout: 3000 });
622
+ });
623
+
624
+ it('calls onStrictModeViolation when strict mode is violated', async () => {
625
+ const { isPermitted } = await import('../../rbac/api');
626
+ vi.mocked(isPermitted).mockResolvedValue(false);
627
+
628
+ const onStrictModeViolation = vi.fn();
629
+
630
+ renderWithProviders(
631
+ <TestWrapper>
632
+ <PaceAppLayout
633
+ {...defaultProps}
634
+ enforcePermissions={true}
635
+ strictMode={true}
636
+ onStrictModeViolation={onStrictModeViolation}
637
+ />
638
+ </TestWrapper>
639
+ );
640
+
641
+ await waitFor(() => {
642
+ expect(onStrictModeViolation).toHaveBeenCalledWith('dashboard', 'read');
643
+ }, { timeout: 3000 });
644
+ });
645
+ });
646
+
647
+ describe('Role-Based Routing', () => {
648
+ it('handles role-based routing when enabled', async () => {
649
+ const onRouteAccessDenied = vi.fn();
650
+ const routeConfig = [
651
+ {
652
+ path: '/dashboard',
653
+ component: () => <div>Dashboard</div>,
654
+ permissions: ['read'],
655
+ roles: ['user'],
656
+ accessLevel: 'standard',
657
+ },
658
+ ];
659
+
660
+ renderWithProviders(
661
+ <TestWrapper>
662
+ <PaceAppLayout
663
+ {...defaultProps}
664
+ roleBasedRouting={true}
665
+ routeConfig={routeConfig}
666
+ onRouteAccessDenied={onRouteAccessDenied}
667
+ />
668
+ </TestWrapper>
669
+ );
670
+
671
+ // Should render normally since route is in config
672
+ expect(screen.getByTestId('header')).toBeInTheDocument();
673
+ });
674
+
675
+ it('handles route not found in configuration', async () => {
676
+ const onRouteStrictModeViolation = vi.fn();
677
+ const routeConfig = [
678
+ {
679
+ path: '/other',
680
+ component: () => <div>Other</div>,
681
+ permissions: ['read'],
682
+ },
683
+ ];
684
+
685
+ // Change location to a route not in config
686
+ mockLocation.pathname = '/unknown';
687
+
688
+ renderWithProviders(
689
+ <TestWrapper>
690
+ <PaceAppLayout
691
+ {...defaultProps}
692
+ roleBasedRouting={true}
693
+ routeConfig={routeConfig}
694
+ strictMode={true}
695
+ onRouteStrictModeViolation={onRouteStrictModeViolation}
696
+ />
697
+ </TestWrapper>
698
+ );
699
+
700
+ await waitFor(() => {
701
+ expect(onRouteStrictModeViolation).toHaveBeenCalledWith('/unknown', 'Route not found in configuration');
702
+ });
703
+ });
704
+ });
705
+
706
+ describe('Error Handling', () => {
707
+ it('handles missing user gracefully', async () => {
708
+ // Mock user as null
709
+ const mockAuthWithoutUser = {
710
+ ...mockUnifiedAuth,
711
+ user: null,
712
+ };
713
+
714
+ // Mock the useUnifiedAuth hook to return null user
715
+ vi.mocked(useUnifiedAuth).mockReturnValue(mockAuthWithoutUser);
716
+
717
+ const { isPermitted } = await import('../../rbac/api');
718
+ vi.mocked(isPermitted).mockResolvedValue(false);
719
+
720
+ renderWithProviders(
721
+ <TestWrapper>
722
+ <PaceAppLayout
723
+ {...defaultProps}
724
+ enforcePermissions={true}
725
+ />
726
+ </TestWrapper>
727
+ );
728
+
729
+ await waitFor(() => {
730
+ expect(screen.getByText('Access Denied')).toBeInTheDocument();
731
+ }, { timeout: 3000 });
732
+ });
733
+
734
+ it('handles missing organisation context', async () => {
735
+ const mockUserWithoutOrg = {
736
+ ...mockUser,
737
+ user_metadata: {},
738
+ app_metadata: {},
739
+ };
740
+
741
+ const mockAuthWithoutOrg = {
742
+ ...mockUnifiedAuth,
743
+ user: mockUserWithoutOrg,
744
+ };
745
+
746
+ // Mock the useUnifiedAuth hook to return user without org context
747
+ vi.mocked(useUnifiedAuth).mockReturnValue(mockAuthWithoutOrg);
748
+
749
+ const { isPermitted } = await import('../../rbac/api');
750
+ vi.mocked(isPermitted).mockResolvedValue(false);
751
+
752
+ renderWithProviders(
753
+ <TestWrapper>
754
+ <PaceAppLayout
755
+ {...defaultProps}
756
+ enforcePermissions={true}
757
+ />
758
+ </TestWrapper>
759
+ );
760
+
761
+ await waitFor(() => {
762
+ expect(screen.getByText('Access Denied')).toBeInTheDocument();
763
+ }, { timeout: 3000 });
764
+ });
765
+ });
766
+
767
+ describe('Accessibility', () => {
768
+ it('has proper semantic structure', () => {
769
+ renderWithProviders(
770
+ <TestWrapper>
771
+ <PaceAppLayout {...defaultProps} />
772
+ </TestWrapper>
773
+ );
774
+
775
+ expect(screen.getByTestId('header')).toBeInTheDocument();
776
+ expect(screen.getByRole('main')).toBeInTheDocument();
777
+ expect(screen.getByTestId('footer')).toBeInTheDocument();
778
+ });
779
+
780
+ it('has accessible navigation', () => {
781
+ renderWithProviders(
782
+ <TestWrapper>
783
+ <PaceAppLayout {...defaultProps} />
784
+ </TestWrapper>
785
+ );
786
+
787
+ expect(screen.getByTestId('header-nav')).toBeInTheDocument();
788
+ expect(screen.getByTestId('nav-item-home')).toBeInTheDocument();
789
+ });
790
+
791
+ it('has accessible user menu', () => {
792
+ renderWithProviders(
793
+ <TestWrapper>
794
+ <PaceAppLayout {...defaultProps} />
795
+ </TestWrapper>
796
+ );
797
+
798
+ expect(screen.getByTestId('header-user-menu')).toBeInTheDocument();
799
+ expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com');
800
+ });
801
+ });
802
+
803
+ describe('Edge Cases', () => {
804
+ it('handles empty navigation items array', () => {
805
+ renderWithProviders(
806
+ <TestWrapper>
807
+ <PaceAppLayout {...defaultProps} navItems={[]} />
808
+ </TestWrapper>
809
+ );
810
+
811
+ expect(screen.getByTestId('header')).toBeInTheDocument();
812
+ expect(screen.getByTestId('header-nav')).toBeInTheDocument();
813
+ });
814
+
815
+ it('handles navigation items without href', () => {
816
+ const navItemsWithoutHref = [
817
+ { id: 'home', label: 'Home', icon: 'Home' },
818
+ { id: 'about', label: 'About', icon: 'Info' },
819
+ ];
820
+
821
+ renderWithProviders(
822
+ <TestWrapper>
823
+ <PaceAppLayout
824
+ {...defaultProps}
825
+ navItems={navItemsWithoutHref}
826
+ filterNavigationByPermissions={false}
827
+ enforcePermissions={false}
828
+ />
829
+ </TestWrapper>
830
+ );
831
+
832
+ expect(screen.getByTestId('nav-item-home')).toBeInTheDocument();
833
+ expect(screen.getByTestId('nav-item-about')).toBeInTheDocument();
834
+ });
835
+
836
+ it('handles rapid permission checks', async () => {
837
+ // This test verifies that the component can handle rapid permission checks
838
+ // without breaking. We don't enforce permissions to simplify the test.
839
+ const { rerender } = renderWithProviders(
840
+ <TestWrapper>
841
+ <PaceAppLayout {...defaultProps} enforcePermissions={false} />
842
+ </TestWrapper>
843
+ );
844
+
845
+ // Wait for initial render
846
+ await waitFor(() => {
847
+ expect(screen.getByTestId('header')).toBeInTheDocument();
848
+ });
849
+
850
+ // Change location to trigger new render
851
+ mockLocation.pathname = '/settings';
852
+ rerender(
853
+ <TestWrapper>
854
+ <PaceAppLayout {...defaultProps} enforcePermissions={false} />
855
+ </TestWrapper>
856
+ );
857
+
858
+ // Should still render header after location change
859
+ expect(screen.getByTestId('header')).toBeInTheDocument();
860
+ });
861
+ });
862
+
863
+ describe('Performance', () => {
864
+ it('does not re-render unnecessarily with stable props', () => {
865
+ const stableProps = {
866
+ appName: 'Test App',
867
+ navItems: [
868
+ { id: 'home', label: 'Home', href: '/', icon: 'Home' },
869
+ ],
870
+ enforcePermissions: false,
871
+ };
872
+
873
+ const { rerender } = renderWithProviders(
874
+ <TestWrapper>
875
+ <PaceAppLayout {...stableProps} />
876
+ </TestWrapper>
877
+ );
878
+
879
+ const initialHeader = screen.getByTestId('header');
880
+
881
+ // Re-render with same props
882
+ rerender(
883
+ <TestWrapper>
884
+ <PaceAppLayout {...stableProps} />
885
+ </TestWrapper>
886
+ );
887
+
888
+ expect(screen.getByTestId('header')).toBeInTheDocument();
889
+ });
890
+ });
891
+ });