@jmruthers/pace-core 0.5.1 → 0.5.3

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 (206) 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 -58
  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 +441 -0
  174. package/src/rbac/hooks/index.ts +21 -0
  175. package/src/rbac/hooks/useCan.test.ts +461 -0
  176. package/src/rbac/hooks/usePermissions.test.ts +359 -0
  177. package/src/rbac/hooks/usePermissions.ts +567 -0
  178. package/src/rbac/hooks/useRBAC.simple.test.ts +90 -0
  179. package/src/rbac/hooks/useRBAC.test.ts +503 -0
  180. package/src/{hooks → rbac/hooks}/useRBAC.ts +7 -7
  181. package/src/rbac/index.ts +5 -10
  182. package/src/{providers → rbac/providers}/RBACProvider.tsx +6 -6
  183. package/src/rbac/providers/__tests__/RBACProvider.test.tsx +687 -0
  184. package/src/rbac/providers/index.ts +11 -0
  185. package/src/styles/core.css +0 -58
  186. package/src/utils/formatDate.test.ts +241 -0
  187. package/dist/chunk-AUE24LVR.js +0 -268
  188. package/dist/chunk-AUE24LVR.js.map +0 -1
  189. package/dist/chunk-COBPIXXQ.js +0 -379
  190. package/dist/chunk-COBPIXXQ.js.map +0 -1
  191. package/dist/chunk-OEGRKULD.js.map +0 -1
  192. package/dist/chunk-OYRY44Q2.js +0 -62
  193. package/dist/chunk-OYRY44Q2.js.map +0 -1
  194. package/dist/chunk-T3XIA4AJ.js.map +0 -1
  195. package/dist/chunk-TGDCLPP2.js.map +0 -1
  196. package/src/components/RBAC/PagePermissionGuard.tsx +0 -287
  197. package/src/components/RBAC/RBACGuard.tsx +0 -143
  198. package/src/components/RBAC/RBACProvider.tsx +0 -186
  199. package/src/components/RBAC/RoleBasedContent.tsx +0 -129
  200. package/src/components/RBAC/index.ts +0 -23
  201. package/src/rbac/hooks.ts +0 -570
  202. /package/dist/{DataTable-GX3XERFJ.js.map → DataTable-ZQDRE46Q.js.map} +0 -0
  203. /package/dist/{api-ETQ6YJ3C.js.map → api-H5A3H4IR.js.map} +0 -0
  204. /package/dist/{chunk-C5G2A4PO.js.map → chunk-GWSBHC4J.js.map} +0 -0
  205. /package/dist/{chunk-5EL3KHOQ.js.map → chunk-K6B7BLSE.js.map} +0 -0
  206. /package/dist/{chunk-GSNM5D6H.js.map → chunk-M4RW7PIP.js.map} +0 -0
@@ -0,0 +1,748 @@
1
+ /**
2
+ * @file OrganisationSelector Component Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/OrganisationSelector
5
+ * @since 0.4.0
6
+ *
7
+ * Comprehensive test suite for the OrganisationSelector component covering all functionality,
8
+ * RBAC behavior, error handling, and accessibility features.
9
+ */
10
+
11
+ import React from 'react';
12
+ import { screen, waitFor } from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15
+ import { OrganisationSelector, OrganisationSelectorProps } from './OrganisationSelector';
16
+ import { renderWithProviders } from '../../__tests__/helpers/test-utils';
17
+ import type { Organisation } from '../../types/organisation';
18
+
19
+ // Mock the useOrganisations hook
20
+ const mockUseOrganisations = vi.fn();
21
+ vi.mock('../../providers/OrganisationProvider', () => ({
22
+ useOrganisations: () => mockUseOrganisations(),
23
+ }));
24
+
25
+ // Mock child components
26
+ let mockOnValueChange: ((value: string) => void) | null = null;
27
+
28
+ vi.mock('../Select', () => ({
29
+ Select: ({ children, value, onValueChange, disabled }: any) => {
30
+ // Store the onValueChange callback for SelectItem to use
31
+ mockOnValueChange = onValueChange;
32
+ return (
33
+ <div data-testid="select" data-value={value} data-disabled={disabled}>
34
+ {children}
35
+ </div>
36
+ );
37
+ },
38
+ SelectContent: ({ children }: any) => (
39
+ <div data-testid="select-content">{children}</div>
40
+ ),
41
+ SelectItem: ({ children, value, disabled, className }: any) => (
42
+ <div
43
+ data-testid={`select-item-${value}`}
44
+ data-disabled={disabled}
45
+ className={className}
46
+ onClick={() => !disabled && mockOnValueChange?.(value)}
47
+ >
48
+ {children}
49
+ </div>
50
+ ),
51
+ SelectTrigger: ({ children, className }: any) => (
52
+ <button data-testid="select-trigger" className={className}>
53
+ {children}
54
+ </button>
55
+ ),
56
+ SelectValue: ({ placeholder }: any) => (
57
+ <span data-testid="select-value">{placeholder}</span>
58
+ ),
59
+ }));
60
+
61
+ vi.mock('../Alert/Alert', () => ({
62
+ Alert: ({ children, variant }: any) => (
63
+ <div data-testid="alert" data-variant={variant}>
64
+ {children}
65
+ </div>
66
+ ),
67
+ AlertDescription: ({ children }: any) => (
68
+ <div data-testid="alert-description">{children}</div>
69
+ ),
70
+ }));
71
+
72
+ vi.mock('../Button/Button', () => ({
73
+ Button: ({ children, onClick, disabled, variant, size, className }: any) => (
74
+ <button
75
+ data-testid="button"
76
+ onClick={onClick}
77
+ disabled={disabled}
78
+ data-variant={variant}
79
+ data-size={size}
80
+ className={className}
81
+ >
82
+ {children}
83
+ </button>
84
+ ),
85
+ }));
86
+
87
+ vi.mock('../LoadingSpinner/LoadingSpinner', () => ({
88
+ LoadingSpinner: ({ size }: any) => (
89
+ <div data-testid="loading-spinner" data-size={size}>Loading...</div>
90
+ ),
91
+ }));
92
+
93
+ // Test data
94
+ const mockOrganisations: Organisation[] = [
95
+ {
96
+ id: 'org-1',
97
+ name: 'acme-corp',
98
+ display_name: 'Acme Corporation',
99
+ description: 'Leading technology company',
100
+ subscription_tier: 'premium',
101
+ settings: {},
102
+ is_active: true,
103
+ created_at: '2023-01-01T00:00:00Z',
104
+ updated_at: '2023-01-01T00:00:00Z',
105
+ },
106
+ {
107
+ id: 'org-2',
108
+ name: 'beta-inc',
109
+ display_name: 'Beta Inc',
110
+ description: 'Innovation focused startup',
111
+ subscription_tier: 'standard',
112
+ settings: {},
113
+ is_active: true,
114
+ created_at: '2023-01-02T00:00:00Z',
115
+ updated_at: '2023-01-02T00:00:00Z',
116
+ },
117
+ {
118
+ id: 'org-3',
119
+ name: 'gamma-ltd',
120
+ display_name: 'Gamma Ltd',
121
+ description: 'Global enterprise solutions',
122
+ subscription_tier: 'enterprise',
123
+ settings: {},
124
+ is_active: true,
125
+ created_at: '2023-01-03T00:00:00Z',
126
+ updated_at: '2023-01-03T00:00:00Z',
127
+ },
128
+ ];
129
+
130
+ const mockSelectedOrganisation = mockOrganisations[0];
131
+
132
+ const defaultMockContext = {
133
+ organisations: mockOrganisations,
134
+ selectedOrganisation: mockSelectedOrganisation,
135
+ isLoading: false,
136
+ error: null,
137
+ switchOrganisation: vi.fn().mockResolvedValue(undefined),
138
+ getUserRole: vi.fn().mockReturnValue('admin'),
139
+ validateOrganisationAccess: vi.fn().mockReturnValue(true),
140
+ refreshOrganisations: vi.fn().mockResolvedValue(undefined),
141
+ };
142
+
143
+ describe('OrganisationSelector Component', () => {
144
+ beforeEach(() => {
145
+ vi.clearAllMocks();
146
+ mockUseOrganisations.mockReturnValue(defaultMockContext);
147
+ });
148
+
149
+ afterEach(() => {
150
+ vi.clearAllMocks();
151
+ });
152
+
153
+ // Basic rendering tests
154
+ describe('Rendering', () => {
155
+ it('renders with default props', () => {
156
+ renderWithProviders(<OrganisationSelector />);
157
+
158
+ expect(screen.getByTestId('select')).toBeInTheDocument();
159
+ expect(screen.getByTestId('select-trigger')).toBeInTheDocument();
160
+ expect(screen.getByTestId('select-value')).toHaveTextContent('Select organisation');
161
+ });
162
+
163
+ it('renders with custom placeholder', () => {
164
+ renderWithProviders(<OrganisationSelector placeholder="Choose organisation..." />);
165
+
166
+ expect(screen.getByTestId('select-value')).toHaveTextContent('Choose organisation...');
167
+ });
168
+
169
+ it('renders with custom className', () => {
170
+ renderWithProviders(<OrganisationSelector className="custom-selector" />);
171
+
172
+ const container = screen.getByTestId('select').parentElement;
173
+ expect(container).toHaveClass('custom-selector');
174
+ });
175
+
176
+ it('renders with proper DOM structure', () => {
177
+ renderWithProviders(<OrganisationSelector />);
178
+
179
+ expect(screen.getByTestId('select')).toBeInTheDocument();
180
+ expect(screen.getByTestId('select-trigger')).toBeInTheDocument();
181
+ expect(screen.getByTestId('select-content')).toBeInTheDocument();
182
+ });
183
+ });
184
+
185
+ // Loading state tests
186
+ describe('Loading State', () => {
187
+ it('renders loading state when organisations are loading', () => {
188
+ mockUseOrganisations.mockReturnValue({
189
+ ...defaultMockContext,
190
+ isLoading: true,
191
+ });
192
+
193
+ renderWithProviders(<OrganisationSelector />);
194
+
195
+ expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
196
+ expect(screen.getByText('Loading organisations...')).toBeInTheDocument();
197
+ });
198
+
199
+ it('renders compact loading state', () => {
200
+ mockUseOrganisations.mockReturnValue({
201
+ ...defaultMockContext,
202
+ isLoading: true,
203
+ });
204
+
205
+ renderWithProviders(<OrganisationSelector compact={true} />);
206
+
207
+ expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
208
+ expect(screen.getAllByText('Loading...')).toHaveLength(2); // Spinner + text
209
+ });
210
+
211
+ it('shows loading spinner in trigger when switching organisations', () => {
212
+ // Test the loading state directly by mocking the loading state
213
+ mockUseOrganisations.mockReturnValue({
214
+ ...defaultMockContext,
215
+ isLoading: true,
216
+ });
217
+
218
+ renderWithProviders(<OrganisationSelector />);
219
+
220
+ // Should show loading state
221
+ expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
222
+ });
223
+ });
224
+
225
+ // Error state tests
226
+ describe('Error State', () => {
227
+ it('renders error state when organisations fail to load', () => {
228
+ const error = new Error('Failed to load organisations');
229
+ mockUseOrganisations.mockReturnValue({
230
+ ...defaultMockContext,
231
+ error,
232
+ organisations: [],
233
+ });
234
+
235
+ renderWithProviders(<OrganisationSelector />);
236
+
237
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
238
+ expect(screen.getByTestId('alert-description')).toHaveTextContent('Failed to load organisations: Failed to load organisations');
239
+ });
240
+
241
+ it('shows retry button when showRetryButton is true', () => {
242
+ const error = new Error('Failed to load organisations');
243
+ mockUseOrganisations.mockReturnValue({
244
+ ...defaultMockContext,
245
+ error,
246
+ organisations: [],
247
+ });
248
+
249
+ renderWithProviders(<OrganisationSelector showRetryButton={true} />);
250
+
251
+ expect(screen.getByTestId('button')).toBeInTheDocument();
252
+ expect(screen.getByTestId('button')).toHaveTextContent('Retry');
253
+ });
254
+
255
+ it('does not show retry button when showRetryButton is false', () => {
256
+ const error = new Error('Failed to load organisations');
257
+ mockUseOrganisations.mockReturnValue({
258
+ ...defaultMockContext,
259
+ error,
260
+ organisations: [],
261
+ });
262
+
263
+ renderWithProviders(<OrganisationSelector showRetryButton={false} />);
264
+
265
+ expect(screen.queryByTestId('button')).not.toBeInTheDocument();
266
+ });
267
+
268
+ it('handles retry button click', async () => {
269
+ const user = userEvent.setup();
270
+ const refreshOrganisations = vi.fn().mockResolvedValue(undefined);
271
+
272
+ const error = new Error('Failed to load organisations');
273
+ mockUseOrganisations.mockReturnValue({
274
+ ...defaultMockContext,
275
+ error,
276
+ organisations: [],
277
+ refreshOrganisations,
278
+ });
279
+
280
+ renderWithProviders(<OrganisationSelector showRetryButton={true} />);
281
+
282
+ const retryButton = screen.getByTestId('button');
283
+ await user.click(retryButton);
284
+
285
+ expect(refreshOrganisations).toHaveBeenCalledTimes(1);
286
+ });
287
+ });
288
+
289
+ // Empty state tests
290
+ describe('Empty State', () => {
291
+ it('renders no organisations message when showNoOrganisationsMessage is true', () => {
292
+ mockUseOrganisations.mockReturnValue({
293
+ ...defaultMockContext,
294
+ organisations: [],
295
+ });
296
+
297
+ renderWithProviders(<OrganisationSelector showNoOrganisationsMessage={true} />);
298
+
299
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
300
+ expect(screen.getByTestId('alert-description')).toHaveTextContent('No organisations available. Please contact your administrator to be added to an organisation.');
301
+ });
302
+
303
+ it('shows check again button when no organisations and showRetryButton is true', () => {
304
+ mockUseOrganisations.mockReturnValue({
305
+ ...defaultMockContext,
306
+ organisations: [],
307
+ });
308
+
309
+ renderWithProviders(<OrganisationSelector showRetryButton={true} />);
310
+
311
+ expect(screen.getByTestId('button')).toBeInTheDocument();
312
+ expect(screen.getByTestId('button')).toHaveTextContent('Check Again');
313
+ });
314
+
315
+ it('returns null when no organisations and showNoOrganisationsMessage is false', () => {
316
+ mockUseOrganisations.mockReturnValue({
317
+ ...defaultMockContext,
318
+ organisations: [],
319
+ });
320
+
321
+ const { container } = renderWithProviders(<OrganisationSelector showNoOrganisationsMessage={false} />);
322
+
323
+ expect(container.firstChild).toBeNull();
324
+ });
325
+ });
326
+
327
+ // Organisation switching tests
328
+ describe('Organisation Switching', () => {
329
+ it('renders all available organisations', () => {
330
+ renderWithProviders(<OrganisationSelector />);
331
+
332
+ mockOrganisations.forEach(org => {
333
+ expect(screen.getByTestId(`select-item-${org.id}`)).toBeInTheDocument();
334
+ });
335
+ });
336
+
337
+ it('displays organisation names and descriptions', () => {
338
+ renderWithProviders(<OrganisationSelector />);
339
+
340
+ expect(screen.getByText('Acme Corporation')).toBeInTheDocument();
341
+ expect(screen.getByText('Leading technology company')).toBeInTheDocument();
342
+ expect(screen.getByText('Beta Inc')).toBeInTheDocument();
343
+ expect(screen.getByText('Innovation focused startup')).toBeInTheDocument();
344
+ });
345
+
346
+ it('handles organisation selection', async () => {
347
+ const user = userEvent.setup();
348
+ const onOrganisationChange = vi.fn();
349
+ const switchOrganisation = vi.fn().mockResolvedValue(undefined);
350
+
351
+ mockUseOrganisations.mockReturnValue({
352
+ ...defaultMockContext,
353
+ switchOrganisation,
354
+ });
355
+
356
+ renderWithProviders(
357
+ <OrganisationSelector onOrganisationChange={onOrganisationChange} />
358
+ );
359
+
360
+ const selectItem = screen.getByTestId('select-item-org-2');
361
+ await user.click(selectItem);
362
+
363
+ expect(switchOrganisation).toHaveBeenCalledWith('org-2');
364
+ });
365
+
366
+ it('calls onOrganisationChange callback after successful switch', async () => {
367
+ const user = userEvent.setup();
368
+ const onOrganisationChange = vi.fn();
369
+ const switchOrganisation = vi.fn().mockResolvedValue(undefined);
370
+
371
+ mockUseOrganisations.mockReturnValue({
372
+ ...defaultMockContext,
373
+ switchOrganisation,
374
+ });
375
+
376
+ renderWithProviders(
377
+ <OrganisationSelector onOrganisationChange={onOrganisationChange} />
378
+ );
379
+
380
+ const selectItem = screen.getByTestId('select-item-org-2');
381
+ await user.click(selectItem);
382
+
383
+ await waitFor(() => {
384
+ expect(onOrganisationChange).toHaveBeenCalledWith(mockOrganisations[1]);
385
+ });
386
+ });
387
+
388
+ it('handles organisation switch errors', () => {
389
+ // Test error state directly by mocking an error
390
+ const error = new Error('Access denied');
391
+ mockUseOrganisations.mockReturnValue({
392
+ ...defaultMockContext,
393
+ error,
394
+ organisations: [],
395
+ });
396
+
397
+ renderWithProviders(<OrganisationSelector />);
398
+
399
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
400
+ expect(screen.getByTestId('alert-description')).toHaveTextContent('Failed to load organisations: Access denied');
401
+ });
402
+
403
+ it('validates organisation access before switching', async () => {
404
+ const user = userEvent.setup();
405
+ const validateOrganisationAccess = vi.fn().mockReturnValue(false);
406
+ const switchOrganisation = vi.fn();
407
+
408
+ mockUseOrganisations.mockReturnValue({
409
+ ...defaultMockContext,
410
+ validateOrganisationAccess,
411
+ switchOrganisation,
412
+ });
413
+
414
+ renderWithProviders(<OrganisationSelector />);
415
+
416
+ const selectItem = screen.getByTestId('select-item-org-2');
417
+ await user.click(selectItem);
418
+
419
+ expect(validateOrganisationAccess).toHaveBeenCalledWith('org-2');
420
+ expect(switchOrganisation).not.toHaveBeenCalled();
421
+ });
422
+ });
423
+
424
+ // RBAC and permission tests
425
+ describe('RBAC and Permissions', () => {
426
+ it('disables organisations without access', () => {
427
+ const validateOrganisationAccess = vi.fn((orgId: string) => orgId !== 'org-2');
428
+
429
+ mockUseOrganisations.mockReturnValue({
430
+ ...defaultMockContext,
431
+ validateOrganisationAccess,
432
+ });
433
+
434
+ renderWithProviders(<OrganisationSelector />);
435
+
436
+ expect(screen.getByTestId('select-item-org-1')).not.toHaveAttribute('data-disabled', 'true');
437
+ expect(screen.getByTestId('select-item-org-2')).toHaveAttribute('data-disabled', 'true');
438
+ expect(screen.getByTestId('select-item-org-3')).not.toHaveAttribute('data-disabled', 'true');
439
+ });
440
+
441
+ it('shows user role when showRole is true', () => {
442
+ const getUserRole = vi.fn().mockReturnValue('admin');
443
+
444
+ mockUseOrganisations.mockReturnValue({
445
+ ...defaultMockContext,
446
+ getUserRole,
447
+ });
448
+
449
+ renderWithProviders(<OrganisationSelector showRole={true} />);
450
+
451
+ // The role should be displayed in the select items
452
+ expect(screen.getAllByText('admin')).toHaveLength(3);
453
+ });
454
+
455
+ it('does not show role when showRole is false', () => {
456
+ const getUserRole = vi.fn().mockReturnValue('admin');
457
+
458
+ mockUseOrganisations.mockReturnValue({
459
+ ...defaultMockContext,
460
+ getUserRole,
461
+ });
462
+
463
+ renderWithProviders(<OrganisationSelector showRole={false} />);
464
+
465
+ expect(screen.queryByText('admin')).not.toBeInTheDocument();
466
+ });
467
+
468
+ it('formats role names correctly', () => {
469
+ const getUserRole = vi.fn().mockReturnValue('super_admin');
470
+
471
+ mockUseOrganisations.mockReturnValue({
472
+ ...defaultMockContext,
473
+ getUserRole,
474
+ });
475
+
476
+ renderWithProviders(<OrganisationSelector showRole={true} />);
477
+
478
+ expect(screen.getAllByText('super admin')).toHaveLength(3);
479
+ });
480
+ });
481
+
482
+ // Compact mode tests
483
+ describe('Compact Mode', () => {
484
+ it('hides descriptions in compact mode', () => {
485
+ renderWithProviders(<OrganisationSelector compact={true} />);
486
+
487
+ expect(screen.queryByText('Leading technology company')).not.toBeInTheDocument();
488
+ expect(screen.queryByText('Innovation focused startup')).not.toBeInTheDocument();
489
+ });
490
+
491
+ it('shows descriptions when not in compact mode', () => {
492
+ renderWithProviders(<OrganisationSelector compact={false} />);
493
+
494
+ expect(screen.getByText('Leading technology company')).toBeInTheDocument();
495
+ expect(screen.getByText('Innovation focused startup')).toBeInTheDocument();
496
+ });
497
+ });
498
+
499
+ // Disabled state tests
500
+ describe('Disabled State', () => {
501
+ it('disables the selector when disabled prop is true', () => {
502
+ renderWithProviders(<OrganisationSelector disabled={true} />);
503
+
504
+ expect(screen.getByTestId('select')).toHaveAttribute('data-disabled', 'true');
505
+ });
506
+
507
+ it('enables the selector when disabled prop is false', () => {
508
+ renderWithProviders(<OrganisationSelector disabled={false} />);
509
+
510
+ expect(screen.getByTestId('select')).toHaveAttribute('data-disabled', 'false');
511
+ });
512
+
513
+ it('disables selector during loading', () => {
514
+ // Test the disabled state directly by mocking the loading state
515
+ mockUseOrganisations.mockReturnValue({
516
+ ...defaultMockContext,
517
+ isLoading: true,
518
+ });
519
+
520
+ renderWithProviders(<OrganisationSelector />);
521
+
522
+ // Should show loading state instead of selector
523
+ expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
524
+ });
525
+ });
526
+
527
+ // Accessibility tests
528
+ describe('Accessibility', () => {
529
+ it('has proper ARIA attributes', () => {
530
+ renderWithProviders(<OrganisationSelector />);
531
+
532
+ const select = screen.getByTestId('select');
533
+ expect(select).toBeInTheDocument();
534
+ });
535
+
536
+ it('provides screen reader accessible content', () => {
537
+ renderWithProviders(<OrganisationSelector />);
538
+
539
+ expect(screen.getByText('Acme Corporation')).toBeInTheDocument();
540
+ expect(screen.getByText('Beta Inc')).toBeInTheDocument();
541
+ });
542
+
543
+ it('shows loading state to screen readers', () => {
544
+ mockUseOrganisations.mockReturnValue({
545
+ ...defaultMockContext,
546
+ isLoading: true,
547
+ });
548
+
549
+ renderWithProviders(<OrganisationSelector />);
550
+
551
+ expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
552
+ expect(screen.getByText('Loading organisations...')).toBeInTheDocument();
553
+ });
554
+
555
+ it('announces errors to screen readers', () => {
556
+ const error = new Error('Failed to load organisations');
557
+ mockUseOrganisations.mockReturnValue({
558
+ ...defaultMockContext,
559
+ error,
560
+ organisations: [],
561
+ });
562
+
563
+ renderWithProviders(<OrganisationSelector />);
564
+
565
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
566
+ expect(screen.getByTestId('alert-description')).toHaveTextContent('Failed to load organisations: Failed to load organisations');
567
+ });
568
+ });
569
+
570
+ // Error handling tests
571
+ describe('Error Handling', () => {
572
+ it('handles switch errors gracefully', () => {
573
+ // Test error state directly by mocking an error
574
+ const error = new Error('Network error');
575
+ mockUseOrganisations.mockReturnValue({
576
+ ...defaultMockContext,
577
+ error,
578
+ organisations: [],
579
+ });
580
+
581
+ renderWithProviders(<OrganisationSelector />);
582
+
583
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
584
+ expect(screen.getByTestId('alert-description')).toHaveTextContent('Failed to load organisations: Network error');
585
+ });
586
+
587
+ it('handles retry errors gracefully', () => {
588
+ // Test error state directly by mocking an error
589
+ const error = new Error('Retry failed');
590
+ mockUseOrganisations.mockReturnValue({
591
+ ...defaultMockContext,
592
+ error,
593
+ organisations: [],
594
+ });
595
+
596
+ renderWithProviders(<OrganisationSelector showRetryButton={true} />);
597
+
598
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
599
+ expect(screen.getByTestId('alert-description')).toHaveTextContent('Failed to load organisations: Retry failed');
600
+ });
601
+
602
+ it('clears errors on successful retry', async () => {
603
+ const user = userEvent.setup();
604
+ const refreshOrganisations = vi.fn().mockResolvedValue(undefined);
605
+
606
+ const error = new Error('Failed to load organisations');
607
+ mockUseOrganisations.mockReturnValue({
608
+ ...defaultMockContext,
609
+ error,
610
+ organisations: [],
611
+ refreshOrganisations,
612
+ });
613
+
614
+ renderWithProviders(<OrganisationSelector showRetryButton={true} />);
615
+
616
+ const retryButton = screen.getByTestId('button');
617
+ await user.click(retryButton);
618
+
619
+ await waitFor(() => {
620
+ expect(refreshOrganisations).toHaveBeenCalledTimes(1);
621
+ });
622
+ });
623
+ });
624
+
625
+ // Edge cases and prop validation tests
626
+ describe('Edge Cases and Prop Validation', () => {
627
+ it('handles empty organisations array', () => {
628
+ mockUseOrganisations.mockReturnValue({
629
+ ...defaultMockContext,
630
+ organisations: [],
631
+ });
632
+
633
+ renderWithProviders(<OrganisationSelector showNoOrganisationsMessage={true} />);
634
+
635
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
636
+ expect(screen.getByTestId('alert-description')).toHaveTextContent('No organisations available');
637
+ });
638
+
639
+ it('handles undefined onOrganisationChange callback', async () => {
640
+ const user = userEvent.setup();
641
+
642
+ renderWithProviders(<OrganisationSelector />);
643
+
644
+ const selectItem = screen.getByTestId('select-item-org-2');
645
+ await user.click(selectItem);
646
+
647
+ // Should not throw error
648
+ expect(screen.getByTestId('select')).toBeInTheDocument();
649
+ });
650
+
651
+ it('handles null selectedOrganisation gracefully', () => {
652
+ mockUseOrganisations.mockReturnValue({
653
+ ...defaultMockContext,
654
+ selectedOrganisation: null,
655
+ isLoading: true, // This should trigger loading state
656
+ });
657
+
658
+ // This should render the loading state instead of crashing
659
+ renderWithProviders(<OrganisationSelector />);
660
+ expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
661
+ });
662
+
663
+ it('handles very long organisation names', () => {
664
+ const longNameOrg = {
665
+ ...mockOrganisations[0],
666
+ display_name: 'A'.repeat(1000),
667
+ };
668
+
669
+ mockUseOrganisations.mockReturnValue({
670
+ ...defaultMockContext,
671
+ organisations: [longNameOrg],
672
+ });
673
+
674
+ renderWithProviders(<OrganisationSelector />);
675
+
676
+ expect(screen.getByText(longNameOrg.display_name)).toBeInTheDocument();
677
+ });
678
+ });
679
+
680
+ // Performance tests
681
+ describe('Performance', () => {
682
+ it('renders quickly with many organisations', () => {
683
+ const manyOrgs = Array.from({ length: 100 }, (_, i) => ({
684
+ ...mockOrganisations[0],
685
+ id: `org-${i}`,
686
+ display_name: `Organisation ${i}`,
687
+ }));
688
+
689
+ mockUseOrganisations.mockReturnValue({
690
+ ...defaultMockContext,
691
+ organisations: manyOrgs,
692
+ });
693
+
694
+ const startTime = performance.now();
695
+ renderWithProviders(<OrganisationSelector />);
696
+ const endTime = performance.now();
697
+
698
+ expect(endTime - startTime).toBeLessThan(100); // Should render in under 100ms
699
+ });
700
+
701
+ it('handles rapid organisation switches', async () => {
702
+ const user = userEvent.setup();
703
+
704
+ renderWithProviders(<OrganisationSelector />);
705
+
706
+ // Rapid switches
707
+ for (let i = 0; i < 5; i++) {
708
+ const selectItem = screen.getByTestId(`select-item-org-${(i % 3) + 1}`);
709
+ await user.click(selectItem);
710
+ }
711
+
712
+ expect(screen.getByTestId('select')).toBeInTheDocument();
713
+ });
714
+ });
715
+
716
+ // Integration tests
717
+ describe('Integration Scenarios', () => {
718
+ it('works with all props enabled', () => {
719
+ renderWithProviders(
720
+ <OrganisationSelector
721
+ placeholder="Choose organisation..."
722
+ className="custom-class"
723
+ showRole={true}
724
+ compact={false}
725
+ showRetryButton={true}
726
+ showNoOrganisationsMessage={true}
727
+ disabled={false}
728
+ />
729
+ );
730
+
731
+ expect(screen.getByTestId('select')).toBeInTheDocument();
732
+ expect(screen.getByText('Choose organisation...')).toBeInTheDocument();
733
+ });
734
+
735
+ it('works in minimal configuration', () => {
736
+ renderWithProviders(
737
+ <OrganisationSelector
738
+ showRole={false}
739
+ compact={true}
740
+ showRetryButton={false}
741
+ showNoOrganisationsMessage={false}
742
+ />
743
+ );
744
+
745
+ expect(screen.getByTestId('select')).toBeInTheDocument();
746
+ });
747
+ });
748
+ });