@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.
- package/dist/{DataTable-GX3XERFJ.js → DataTable-ZQDRE46Q.js} +7 -6
- package/dist/{PublicLoadingSpinner-DztrzuJr.d.ts → PublicLoadingSpinner-Bq_-BeK-.d.ts} +1 -1
- package/dist/RBACProvider-BO4ilsQB.d.ts +63 -0
- package/dist/{UnifiedAuthProvider-w66zSCUf.d.ts → UnifiedAuthProvider-DGQsy-vY.d.ts} +2 -59
- package/dist/{api-ETQ6YJ3C.js → api-H5A3H4IR.js} +2 -2
- package/dist/{chunk-T3XIA4AJ.js → chunk-5H3C2SWM.js} +14 -16
- package/dist/chunk-5H3C2SWM.js.map +1 -0
- package/dist/chunk-5SIXIV7R.js +1925 -0
- package/dist/chunk-5SIXIV7R.js.map +1 -0
- package/dist/chunk-GNTALZV3.js +17 -0
- package/dist/chunk-GNTALZV3.js.map +1 -0
- package/dist/{chunk-C5G2A4PO.js → chunk-GWSBHC4J.js} +6 -6
- package/dist/{chunk-XJK2J4N6.js → chunk-HD7PYDUV.js} +4 -6
- package/dist/{chunk-XJK2J4N6.js.map → chunk-HD7PYDUV.js.map} +1 -1
- package/dist/{chunk-TGDCLPP2.js → chunk-HXX35Q2M.js} +6 -21
- package/dist/chunk-HXX35Q2M.js.map +1 -0
- package/dist/{chunk-5EL3KHOQ.js → chunk-K6B7BLSE.js} +2 -2
- package/dist/{chunk-GSNM5D6H.js → chunk-M4RW7PIP.js} +4 -4
- package/dist/{chunk-U6JDHVC2.js → chunk-PVMYVQSM.js} +6 -8
- package/dist/{chunk-U6JDHVC2.js.map → chunk-PVMYVQSM.js.map} +1 -1
- package/dist/{chunk-6CR3MRZN.js → chunk-QKHFMQ5R.js} +372 -11
- package/dist/{chunk-6CR3MRZN.js.map → chunk-QKHFMQ5R.js.map} +1 -1
- package/dist/chunk-QVYBYGT2.js +428 -0
- package/dist/chunk-QVYBYGT2.js.map +1 -0
- package/dist/{chunk-OEGRKULD.js → chunk-WJARTBCT.js} +56 -1
- package/dist/chunk-WJARTBCT.js.map +1 -0
- package/dist/components.d.ts +4 -3
- package/dist/components.js +16 -162
- package/dist/components.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +7 -9
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +152 -17
- package/dist/index.js.map +1 -1
- package/dist/providers.d.ts +3 -2
- package/dist/providers.js +6 -12
- package/dist/rbac/index.d.ts +167 -98
- package/dist/rbac/index.js +48 -1881
- package/dist/rbac/index.js.map +1 -1
- package/dist/styles/core.css +0 -55
- package/dist/types.d.ts +2 -2
- package/dist/{unified-CM7T0aTK.d.ts → unified-CMPjE_fv.d.ts} +1 -1
- package/dist/{usePublicRouteParams-B6i0KtXW.d.ts → usePublicRouteParams-B2OcAsur.d.ts} +1 -1
- package/dist/utils.js +12 -14
- package/dist/utils.js.map +1 -1
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +73 -0
- package/docs/api/classes/MissingUserContextError.md +66 -0
- package/docs/api/classes/OrganisationContextRequiredError.md +66 -0
- package/docs/api/classes/PermissionDeniedError.md +73 -0
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +270 -0
- package/docs/api/classes/RBACCache.md +284 -0
- package/docs/api/classes/RBACEngine.md +141 -0
- package/docs/api/classes/RBACError.md +76 -0
- package/docs/api/classes/RBACNotInitializedError.md +66 -0
- package/docs/api/classes/SecureSupabaseClient.md +135 -0
- package/docs/api/interfaces/AggregateConfig.md +1 -1
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +96 -0
- package/docs/api/interfaces/DataTableAction.md +1 -1
- package/docs/api/interfaces/DataTableColumn.md +1 -1
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
- package/docs/api/interfaces/EmptyStateConfig.md +1 -1
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +235 -0
- package/docs/api/interfaces/EventContextType.md +1 -1
- package/docs/api/interfaces/EventLogoProps.md +1 -1
- package/docs/api/interfaces/EventProviderProps.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +107 -0
- package/docs/api/interfaces/NavigationContextType.md +164 -0
- package/docs/api/interfaces/NavigationGuardProps.md +139 -0
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +117 -0
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +2 -2
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
- package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
- package/docs/api/interfaces/PageAccessRecord.md +85 -0
- package/docs/api/interfaces/PagePermissionContextType.md +140 -0
- package/docs/api/interfaces/PagePermissionGuardProps.md +153 -0
- package/docs/api/interfaces/PagePermissionProviderProps.md +119 -0
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +153 -0
- package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
- package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +99 -0
- package/docs/api/interfaces/RBACContextType.md +474 -0
- package/docs/api/interfaces/RBACLogger.md +112 -0
- package/docs/api/interfaces/RBACProviderProps.md +107 -0
- package/docs/api/interfaces/RoleBasedRouterContextType.md +151 -0
- package/docs/api/interfaces/RoleBasedRouterProps.md +156 -0
- package/docs/api/interfaces/RouteAccessRecord.md +107 -0
- package/docs/api/interfaces/RouteConfig.md +121 -0
- package/docs/api/interfaces/SecureDataContextType.md +168 -0
- package/docs/api/interfaces/SecureDataProviderProps.md +132 -0
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +85 -85
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +11 -11
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +2244 -3
- package/docs/migration-guide.md +43 -18
- package/docs/styles/README.md +187 -98
- package/docs/usage.md +32 -7
- package/package.json +2 -2
- package/src/components/Footer/Footer.test.tsx +482 -0
- package/src/components/Form/Form.test.tsx +1158 -0
- package/src/components/Header/Header.test.tsx +582 -0
- package/src/components/Header/Header.tsx +1 -1
- package/src/components/InactivityWarningModal/InactivityWarningModal.test.tsx +489 -0
- package/src/components/Input/Input.test.tsx +466 -0
- package/src/components/LoadingSpinner/LoadingSpinner.test.tsx +450 -0
- package/src/components/LoginForm/LoginForm.test.tsx +816 -0
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +883 -0
- package/src/components/OrganisationSelector/OrganisationSelector.test.tsx +748 -0
- package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +891 -0
- package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +475 -0
- package/src/components/PasswordReset/PasswordChangeForm.test.tsx +621 -0
- package/src/components/PasswordReset/PasswordResetForm.test.tsx +605 -0
- package/src/components/Select/Select.test.tsx +948 -0
- package/src/components/SuperAdminGuard.tsx +1 -1
- package/src/components/Toast/Toast.test.tsx +586 -0
- package/src/components/Tooltip/Tooltip.test.tsx +852 -0
- package/src/components/UserMenu/UserMenu.test.tsx +702 -0
- package/src/components/UserMenu/UserMenu.tsx +2 -2
- package/src/hooks/useDebounce.test.ts +375 -0
- package/src/hooks/useOrganisationPermissions.test.ts +528 -0
- package/src/hooks/useOrganisationSecurity.test.ts +734 -0
- package/src/hooks/usePermissionCache.test.ts +542 -0
- package/src/hooks/usePermissionCache.ts +1 -1
- package/src/index.ts +2 -3
- package/src/providers/UnifiedAuthProvider.tsx +2 -2
- package/src/providers/index.ts +3 -1
- package/src/rbac/__tests__/integration.test.tsx +218 -0
- package/src/rbac/api.test.ts +952 -0
- package/src/rbac/components/__tests__/NavigationGuard.test.tsx +843 -0
- package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1007 -0
- package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +806 -0
- package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +741 -0
- package/src/rbac/hooks/index.ts +21 -0
- package/src/rbac/hooks/useCan.test.ts +461 -0
- package/src/rbac/hooks/usePermissions.test.ts +364 -0
- package/src/rbac/hooks/usePermissions.ts +567 -0
- package/src/rbac/hooks/useRBAC.simple.test.ts +90 -0
- package/src/rbac/hooks/useRBAC.test.ts +551 -0
- package/src/{hooks → rbac/hooks}/useRBAC.ts +7 -7
- package/src/rbac/index.ts +5 -10
- package/src/{providers → rbac/providers}/RBACProvider.tsx +6 -6
- package/src/rbac/providers/__tests__/RBACProvider.test.tsx +687 -0
- package/src/rbac/providers/index.ts +11 -0
- package/src/styles/core.css +0 -55
- package/src/utils/formatDate.test.ts +241 -0
- package/dist/chunk-AUE24LVR.js +0 -268
- package/dist/chunk-AUE24LVR.js.map +0 -1
- package/dist/chunk-COBPIXXQ.js +0 -379
- package/dist/chunk-COBPIXXQ.js.map +0 -1
- package/dist/chunk-OEGRKULD.js.map +0 -1
- package/dist/chunk-OYRY44Q2.js +0 -62
- package/dist/chunk-OYRY44Q2.js.map +0 -1
- package/dist/chunk-T3XIA4AJ.js.map +0 -1
- package/dist/chunk-TGDCLPP2.js.map +0 -1
- package/src/components/RBAC/PagePermissionGuard.tsx +0 -287
- package/src/components/RBAC/RBACGuard.tsx +0 -143
- package/src/components/RBAC/RBACProvider.tsx +0 -186
- package/src/components/RBAC/RoleBasedContent.tsx +0 -129
- package/src/components/RBAC/index.ts +0 -23
- package/src/rbac/hooks.ts +0 -570
- /package/dist/{DataTable-GX3XERFJ.js.map → DataTable-ZQDRE46Q.js.map} +0 -0
- /package/dist/{api-ETQ6YJ3C.js.map → api-H5A3H4IR.js.map} +0 -0
- /package/dist/{chunk-C5G2A4PO.js.map → chunk-GWSBHC4J.js.map} +0 -0
- /package/dist/{chunk-5EL3KHOQ.js.map → chunk-K6B7BLSE.js.map} +0 -0
- /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
|
+
});
|