@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,702 @@
1
+ /**
2
+ * @file UserMenu Component Tests
3
+ * @description Comprehensive test suite for UserMenu and UserMenuLoading components
4
+ * @example Excellent test patterns and best practices following testing guidelines
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 { User } from '@supabase/supabase-js';
12
+ import { UserMenu, UserMenuLoading } from './UserMenu';
13
+ import { renderWithProviders } from '../../__tests__/helpers/test-utils';
14
+
15
+ // Mock lucide-react icons
16
+ vi.mock('lucide-react', () => ({
17
+ ChevronDown: () => <span data-testid="chevron-down">▼</span>,
18
+ LogOut: () => <span data-testid="log-out">🚪</span>,
19
+ KeyRound: () => <span data-testid="key-round">🔑</span>,
20
+ }));
21
+
22
+ // Mock the PasswordChangeForm component
23
+ vi.mock('../PasswordReset/PasswordChangeForm', () => ({
24
+ PasswordChangeForm: ({ onSubmit }: { onSubmit: (values: any) => Promise<{ error?: any }> }) => {
25
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
26
+ const [error, setError] = React.useState<string | null>(null);
27
+
28
+ const handleSubmit = async (e: React.FormEvent) => {
29
+ e.preventDefault();
30
+ setIsSubmitting(true);
31
+ setError(null);
32
+ try {
33
+ const result = await onSubmit({ newPassword: 'newpass123', confirmPassword: 'newpass123' });
34
+ if (result.error) {
35
+ setError(result.error.message);
36
+ }
37
+ } catch (err: any) {
38
+ setError(err.message);
39
+ } finally {
40
+ setIsSubmitting(false);
41
+ }
42
+ };
43
+
44
+ return (
45
+ <form data-testid="password-change-form" onSubmit={handleSubmit}>
46
+ <input data-testid="new-password" type="password" placeholder="New Password" />
47
+ <input data-testid="confirm-password" type="password" placeholder="Confirm Password" />
48
+ {error && <div data-testid="error-message">{error}</div>}
49
+ <button
50
+ type="submit"
51
+ disabled={isSubmitting}
52
+ >
53
+ {isSubmitting ? 'Changing...' : 'Change Password'}
54
+ </button>
55
+ </form>
56
+ );
57
+ },
58
+ }));
59
+
60
+ // Mock the Select components
61
+ vi.mock('../Select', () => ({
62
+ Select: ({ children, className }: { children: React.ReactNode; className?: string }) => (
63
+ <div data-testid="select" className={className}>
64
+ {children}
65
+ </div>
66
+ ),
67
+ SelectContent: ({ children }: { children: React.ReactNode }) => (
68
+ <div data-testid="select-content" role="menu">
69
+ {children}
70
+ </div>
71
+ ),
72
+ SelectItem: ({ children, value, onClick }: { children: React.ReactNode; value: string; onClick?: () => void }) => (
73
+ <button data-testid={`select-item-${value}`} onClick={onClick} role="menuitem">
74
+ {children}
75
+ </button>
76
+ ),
77
+ SelectLabel: ({ children, className }: { children: React.ReactNode; className?: string }) => (
78
+ <div data-testid="select-label" className={className}>
79
+ {children}
80
+ </div>
81
+ ),
82
+ SelectSeparator: () => <hr data-testid="select-separator" />,
83
+ SelectTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => (
84
+ <div data-testid="select-trigger">
85
+ {asChild ? children : <button>{children}</button>}
86
+ </div>
87
+ ),
88
+ SelectValue: () => <span data-testid="select-value" />,
89
+ }));
90
+
91
+ // Mock the Dialog components
92
+ vi.mock('../Dialog', () => ({
93
+ Dialog: ({ children, open, onOpenChange }: { children: React.ReactNode; open: boolean; onOpenChange: (open: boolean) => void }) => (
94
+ <div data-testid="dialog" data-open={open}>
95
+ {children}
96
+ </div>
97
+ ),
98
+ DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
99
+ <div data-testid="dialog-content" className={className}>
100
+ {children}
101
+ </div>
102
+ ),
103
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
104
+ <div data-testid="dialog-header">
105
+ {children}
106
+ </div>
107
+ ),
108
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
109
+ <h2 data-testid="dialog-title">{children}</h2>
110
+ ),
111
+ DialogTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => (
112
+ <div data-testid="dialog-trigger">
113
+ {asChild ? children : <button>{children}</button>}
114
+ </div>
115
+ ),
116
+ DialogOverlay: () => <div data-testid="dialog-overlay" />,
117
+ }));
118
+
119
+ // Mock the Avatar components
120
+ vi.mock('../Avatar', () => ({
121
+ Avatar: ({ children, className }: { children: React.ReactNode; className?: string }) => (
122
+ <div data-testid="avatar" className={className}>
123
+ {children}
124
+ </div>
125
+ ),
126
+ AvatarFallback: ({ children }: { children: React.ReactNode }) => (
127
+ <div data-testid="avatar-fallback">{children}</div>
128
+ ),
129
+ AvatarImage: ({ src, alt }: { src?: string; alt?: string }) => (
130
+ <img data-testid="avatar-image" src={src} alt={alt} />
131
+ ),
132
+ }));
133
+
134
+ // Mock the Button component
135
+ vi.mock('../Button', () => ({
136
+ Button: ({ children, variant, className, ...props }: any) => (
137
+ <button
138
+ data-testid="button"
139
+ className={className}
140
+ data-variant={variant}
141
+ {...props}
142
+ >
143
+ {children}
144
+ </button>
145
+ ),
146
+ }));
147
+
148
+ // Test data factories
149
+ const createMockUser = (overrides: Partial<User> = {}): User => ({
150
+ id: '1',
151
+ email: 'test@example.com',
152
+ user_metadata: {
153
+ display_name: 'Test User',
154
+ full_name: 'Test User Full',
155
+ avatar_url: 'https://example.com/avatar.jpg',
156
+ },
157
+ ...overrides,
158
+ } as User);
159
+
160
+ const createMockUserWithEmailOnly = (): User => ({
161
+ id: '2',
162
+ email: 'user@example.com',
163
+ user_metadata: {},
164
+ } as User);
165
+
166
+ describe('UserMenu Component', () => {
167
+ beforeEach(() => {
168
+ vi.clearAllMocks();
169
+ });
170
+
171
+ describe('Rendering', () => {
172
+ it('renders with user information', () => {
173
+ const user = createMockUser();
174
+ renderWithProviders(<UserMenu user={user} />);
175
+
176
+ expect(screen.getByRole('button', { name: 'Test User' })).toBeInTheDocument();
177
+ expect(screen.getByTestId('avatar-image')).toHaveAttribute('src', 'https://example.com/avatar.jpg');
178
+ });
179
+
180
+ it('renders with custom className', () => {
181
+ const user = createMockUser();
182
+ renderWithProviders(<UserMenu user={user} className="custom-menu" />);
183
+
184
+ expect(screen.getByTestId('select')).toHaveClass('custom-menu');
185
+ });
186
+
187
+ it('renders without avatar when showAvatar is false', () => {
188
+ const user = createMockUser();
189
+ renderWithProviders(<UserMenu user={user} showAvatar={false} />);
190
+
191
+ expect(screen.queryByTestId('avatar')).not.toBeInTheDocument();
192
+ expect(screen.getByRole('button', { name: 'Test User' })).toBeInTheDocument();
193
+ });
194
+
195
+ it('renders with avatar when showAvatar is true (default)', () => {
196
+ const user = createMockUser();
197
+ renderWithProviders(<UserMenu user={user} />);
198
+
199
+ expect(screen.getByTestId('avatar')).toBeInTheDocument();
200
+ expect(screen.getByTestId('avatar-image')).toHaveAttribute('src', 'https://example.com/avatar.jpg');
201
+ expect(screen.getByTestId('avatar-image')).toHaveAttribute('alt', 'Test User');
202
+ });
203
+
204
+ it('renders with fallback initial when no avatar URL', () => {
205
+ const user = createMockUser({ user_metadata: { display_name: 'John Doe' } });
206
+ renderWithProviders(<UserMenu user={user} />);
207
+
208
+ expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('J');
209
+ });
210
+
211
+ it('renders with email as display name when no display_name or full_name', () => {
212
+ const user = createMockUserWithEmailOnly();
213
+ renderWithProviders(<UserMenu user={user} />);
214
+
215
+ expect(screen.getByRole('button', { name: 'user' })).toBeInTheDocument();
216
+ });
217
+
218
+ it('renders with email fallback initial when no name metadata', () => {
219
+ const user = createMockUserWithEmailOnly();
220
+ renderWithProviders(<UserMenu user={user} />);
221
+
222
+ expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('U');
223
+ });
224
+
225
+ it('renders with U fallback when no user metadata at all', () => {
226
+ const user = { id: '3', email: null } as User;
227
+ renderWithProviders(<UserMenu user={user} />);
228
+
229
+ expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('U');
230
+ });
231
+ });
232
+
233
+ describe('User Information Display', () => {
234
+ it('displays user name and email in dropdown', () => {
235
+ const user = createMockUser();
236
+ renderWithProviders(<UserMenu user={user} />);
237
+
238
+ // Open the dropdown by clicking the trigger
239
+ const trigger = screen.getByRole('button', { name: 'Test User' });
240
+ trigger.click();
241
+
242
+ expect(screen.getByRole('menuitem', { name: '🔑 Change Password' })).toBeInTheDocument();
243
+ expect(screen.getByText('test@example.com')).toBeInTheDocument();
244
+ });
245
+
246
+ it('prioritizes display_name over full_name', () => {
247
+ const user = createMockUser({
248
+ user_metadata: {
249
+ display_name: 'Display Name',
250
+ full_name: 'Full Name',
251
+ }
252
+ });
253
+ renderWithProviders(<UserMenu user={user} />);
254
+
255
+ expect(screen.getByRole('button', { name: 'Display Name' })).toBeInTheDocument();
256
+ });
257
+
258
+ it('falls back to full_name when display_name is not available', () => {
259
+ const user = createMockUser({
260
+ user_metadata: {
261
+ full_name: 'Full Name',
262
+ }
263
+ });
264
+ renderWithProviders(<UserMenu user={user} />);
265
+
266
+ expect(screen.getByRole('button', { name: 'Full Name' })).toBeInTheDocument();
267
+ });
268
+
269
+ it('falls back to email prefix when no name metadata', () => {
270
+ const user = createMockUserWithEmailOnly();
271
+ renderWithProviders(<UserMenu user={user} />);
272
+
273
+ expect(screen.getByRole('button', { name: 'user' })).toBeInTheDocument();
274
+ });
275
+ });
276
+
277
+ describe('Menu Items', () => {
278
+ it('renders change password menu item', () => {
279
+ const user = createMockUser();
280
+ renderWithProviders(<UserMenu user={user} />);
281
+
282
+ const trigger = screen.getByRole('button', { name: 'Test User' });
283
+ trigger.click();
284
+
285
+ expect(screen.getByTestId('select-item-change-password')).toBeInTheDocument();
286
+ expect(screen.getByRole('menuitem', { name: '🔑 Change Password' })).toBeInTheDocument();
287
+ });
288
+
289
+ it('renders sign out menu item', () => {
290
+ const user = createMockUser();
291
+ renderWithProviders(<UserMenu user={user} />);
292
+
293
+ const trigger = screen.getByRole('button', { name: 'Test User' });
294
+ trigger.click();
295
+
296
+ expect(screen.getByTestId('select-item-sign-out')).toBeInTheDocument();
297
+ expect(screen.getByText('Sign out')).toBeInTheDocument();
298
+ });
299
+
300
+ it('renders menu separator', () => {
301
+ const user = createMockUser();
302
+ renderWithProviders(<UserMenu user={user} />);
303
+
304
+ const trigger = screen.getByRole('button', { name: 'Test User' });
305
+ trigger.click();
306
+
307
+ expect(screen.getByTestId('select-separator')).toBeInTheDocument();
308
+ });
309
+ });
310
+
311
+ describe('Event Handling', () => {
312
+ it('calls onSignOut when sign out is clicked', async () => {
313
+ const user = createMockUser();
314
+ const handleSignOut = vi.fn();
315
+ const userEventInstance = userEvent.setup();
316
+
317
+ renderWithProviders(<UserMenu user={user} onSignOut={handleSignOut} />);
318
+
319
+ const trigger = screen.getByRole('button', { name: 'Test User' });
320
+ await userEventInstance.click(trigger);
321
+
322
+ const signOutButton = screen.getByTestId('select-item-sign-out');
323
+ await userEventInstance.click(signOutButton);
324
+
325
+ expect(handleSignOut).toHaveBeenCalledTimes(1);
326
+ });
327
+
328
+ it('does not call onSignOut when not provided', async () => {
329
+ const user = createMockUser();
330
+ const userEventInstance = userEvent.setup();
331
+
332
+ renderWithProviders(<UserMenu user={user} />);
333
+
334
+ const trigger = screen.getByRole('button', { name: 'Test User' });
335
+ await userEventInstance.click(trigger);
336
+
337
+ const signOutButton = screen.getByTestId('select-item-sign-out');
338
+ await userEventInstance.click(signOutButton);
339
+
340
+ // Should not throw error
341
+ expect(screen.getByTestId('select-item-sign-out')).toBeInTheDocument();
342
+ });
343
+
344
+ it('opens password change dialog when change password is clicked', async () => {
345
+ const user = createMockUser();
346
+ const userEventInstance = userEvent.setup();
347
+
348
+ renderWithProviders(<UserMenu user={user} />);
349
+
350
+ const trigger = screen.getByRole('button', { name: 'Test User' });
351
+ await userEventInstance.click(trigger);
352
+
353
+ const changePasswordButton = screen.getByTestId('select-item-change-password');
354
+ await userEventInstance.click(changePasswordButton);
355
+
356
+ expect(screen.getByTestId('dialog-content')).toBeInTheDocument();
357
+ expect(screen.getByRole('heading', { name: 'Change Password' })).toBeInTheDocument();
358
+ });
359
+ });
360
+
361
+ describe('Password Change Dialog', () => {
362
+ it('renders password change form in dialog', async () => {
363
+ const user = createMockUser();
364
+ const userEventInstance = userEvent.setup();
365
+
366
+ renderWithProviders(<UserMenu user={user} />);
367
+
368
+ const trigger = screen.getByRole('button', { name: 'Test User' });
369
+ await userEventInstance.click(trigger);
370
+
371
+ const changePasswordButton = screen.getByTestId('select-item-change-password');
372
+ await userEventInstance.click(changePasswordButton);
373
+
374
+ expect(screen.getByTestId('password-change-form')).toBeInTheDocument();
375
+ expect(screen.getByTestId('new-password')).toBeInTheDocument();
376
+ expect(screen.getByTestId('confirm-password')).toBeInTheDocument();
377
+ });
378
+
379
+ it('calls onChangePassword when form is submitted', async () => {
380
+ const user = createMockUser();
381
+ const handleChangePassword = vi.fn().mockResolvedValue({});
382
+ const userEventInstance = userEvent.setup();
383
+
384
+ renderWithProviders(<UserMenu user={user} onChangePassword={handleChangePassword} />);
385
+
386
+ const trigger = screen.getByRole('button', { name: 'Test User' });
387
+ await userEventInstance.click(trigger);
388
+
389
+ const changePasswordButton = screen.getByTestId('select-item-change-password');
390
+ await userEventInstance.click(changePasswordButton);
391
+
392
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
393
+ await userEventInstance.click(submitButton);
394
+
395
+ expect(handleChangePassword).toHaveBeenCalledWith('newpass123', 'newpass123');
396
+ });
397
+
398
+ it('calls onChangePassword when form is submitted successfully', async () => {
399
+ const user = createMockUser();
400
+ const handleChangePassword = vi.fn().mockResolvedValue({});
401
+ const userEventInstance = userEvent.setup();
402
+
403
+ renderWithProviders(<UserMenu user={user} onChangePassword={handleChangePassword} />);
404
+
405
+ const trigger = screen.getByRole('button', { name: 'Test User' });
406
+ await userEventInstance.click(trigger);
407
+
408
+ const changePasswordButton = screen.getByTestId('select-item-change-password');
409
+ await userEventInstance.click(changePasswordButton);
410
+
411
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
412
+ await userEventInstance.click(submitButton);
413
+
414
+ // Wait for the form submission to complete
415
+ await waitFor(() => {
416
+ expect(handleChangePassword).toHaveBeenCalledWith('newpass123', 'newpass123');
417
+ });
418
+ });
419
+
420
+ it('keeps dialog open when password change fails', async () => {
421
+ const user = createMockUser();
422
+ const handleChangePassword = vi.fn().mockResolvedValue({ error: { message: 'Password too weak' } });
423
+ const userEventInstance = userEvent.setup();
424
+
425
+ renderWithProviders(<UserMenu user={user} onChangePassword={handleChangePassword} />);
426
+
427
+ const trigger = screen.getByRole('button', { name: 'Test User' });
428
+ await userEventInstance.click(trigger);
429
+
430
+ const changePasswordButton = screen.getByTestId('select-item-change-password');
431
+ await userEventInstance.click(changePasswordButton);
432
+
433
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
434
+ await userEventInstance.click(submitButton);
435
+
436
+ // Wait for the error to be handled
437
+ await waitFor(() => {
438
+ expect(handleChangePassword).toHaveBeenCalled();
439
+ expect(screen.getByTestId('error-message')).toHaveTextContent('Password too weak');
440
+ });
441
+
442
+ // Dialog should still be open
443
+ expect(screen.getByTestId('dialog-content')).toBeInTheDocument();
444
+ });
445
+
446
+ it('handles password change error gracefully', async () => {
447
+ const user = createMockUser();
448
+ const handleChangePassword = vi.fn().mockRejectedValue(new Error('Network error'));
449
+ const userEventInstance = userEvent.setup();
450
+
451
+ renderWithProviders(<UserMenu user={user} onChangePassword={handleChangePassword} />);
452
+
453
+ const trigger = screen.getByRole('button', { name: 'Test User' });
454
+ await userEventInstance.click(trigger);
455
+
456
+ const changePasswordButton = screen.getByTestId('select-item-change-password');
457
+ await userEventInstance.click(changePasswordButton);
458
+
459
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
460
+ await userEventInstance.click(submitButton);
461
+
462
+ // Wait for the error to be handled
463
+ await waitFor(() => {
464
+ expect(handleChangePassword).toHaveBeenCalled();
465
+ expect(screen.getByTestId('error-message')).toHaveTextContent('Network error');
466
+ });
467
+
468
+ // Should not throw error and dialog should remain open
469
+ expect(screen.getByTestId('dialog-content')).toBeInTheDocument();
470
+ });
471
+ });
472
+
473
+ describe('Edge Cases', () => {
474
+ it('returns null when user is null', () => {
475
+ const { container } = renderWithProviders(<UserMenu user={null} />);
476
+ expect(container.firstChild).toBeNull();
477
+ });
478
+
479
+ it('returns null when user is undefined', () => {
480
+ const { container } = renderWithProviders(<UserMenu user={undefined as any} />);
481
+ expect(container.firstChild).toBeNull();
482
+ });
483
+
484
+ it('handles user with minimal data', () => {
485
+ const user = { id: '1', email: 'minimal@example.com' } as User;
486
+ renderWithProviders(<UserMenu user={user} />);
487
+
488
+ expect(screen.getByRole('button', { name: 'minimal' })).toBeInTheDocument();
489
+ expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('M');
490
+ });
491
+
492
+ it('handles user with empty user_metadata', () => {
493
+ const user = { id: '1', email: 'empty@example.com', user_metadata: {} } as User;
494
+ renderWithProviders(<UserMenu user={user} />);
495
+
496
+ expect(screen.getByRole('button', { name: 'empty' })).toBeInTheDocument();
497
+ expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('E');
498
+ });
499
+
500
+ it('handles user with null email', () => {
501
+ const user = { id: '1', email: null, user_metadata: {} } as User;
502
+ renderWithProviders(<UserMenu user={user} />);
503
+
504
+ expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('U');
505
+ });
506
+ });
507
+
508
+ describe('Accessibility', () => {
509
+ it('has proper ARIA labels', () => {
510
+ const user = createMockUser();
511
+ renderWithProviders(<UserMenu user={user} />);
512
+
513
+ expect(screen.getByRole('button', { name: 'Test User' })).toBeInTheDocument();
514
+ });
515
+
516
+ it('supports keyboard navigation', async () => {
517
+ const user = createMockUser();
518
+ const userEventInstance = userEvent.setup();
519
+
520
+ renderWithProviders(<UserMenu user={user} />);
521
+
522
+ const trigger = screen.getByRole('button', { name: 'Test User' });
523
+ trigger.focus();
524
+ expect(trigger).toHaveFocus();
525
+
526
+ await userEventInstance.keyboard('{Enter}');
527
+ expect(screen.getByTestId('select-content')).toBeInTheDocument();
528
+ });
529
+
530
+ it('has proper menu structure', () => {
531
+ const user = createMockUser();
532
+ renderWithProviders(<UserMenu user={user} />);
533
+
534
+ const trigger = screen.getByRole('button', { name: 'Test User' });
535
+ trigger.click();
536
+
537
+ expect(screen.getByRole('menu')).toBeInTheDocument();
538
+ expect(screen.getAllByRole('menuitem')).toHaveLength(2);
539
+ });
540
+ });
541
+
542
+ describe('Performance', () => {
543
+ it('memoizes user info calculation', () => {
544
+ const user = createMockUser();
545
+ const { rerender } = renderWithProviders(<UserMenu user={user} />);
546
+
547
+ // Re-render with same user
548
+ rerender(<UserMenu user={user} />);
549
+
550
+ expect(screen.getByRole('button', { name: 'Test User' })).toBeInTheDocument();
551
+ });
552
+
553
+ it('updates when user changes', () => {
554
+ const user1 = createMockUser({ user_metadata: { display_name: 'User 1' } });
555
+ const user2 = createMockUser({ user_metadata: { display_name: 'User 2' } });
556
+
557
+ const { rerender } = renderWithProviders(<UserMenu user={user1} />);
558
+ expect(screen.getByRole('button', { name: 'User 1' })).toBeInTheDocument();
559
+
560
+ rerender(<UserMenu user={user2} />);
561
+ expect(screen.getByRole('button', { name: 'User 2' })).toBeInTheDocument();
562
+ });
563
+ });
564
+ });
565
+
566
+ describe('UserMenuLoading Component', () => {
567
+ beforeEach(() => {
568
+ vi.clearAllMocks();
569
+ });
570
+
571
+ describe('Rendering', () => {
572
+ it('renders loading state', () => {
573
+ renderWithProviders(<UserMenuLoading />);
574
+
575
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
576
+ expect(screen.getByRole('button')).toBeDisabled();
577
+ });
578
+
579
+ it('renders with proper loading classes', () => {
580
+ renderWithProviders(<UserMenuLoading />);
581
+
582
+ const button = screen.getByRole('button');
583
+ expect(button).toHaveClass('flex', 'items-center', 'space-x-2');
584
+
585
+ // Check for animate-pulse on the inner div
586
+ const animatedDiv = button.querySelector('.animate-pulse');
587
+ expect(animatedDiv).toBeInTheDocument();
588
+ });
589
+
590
+ it('renders loading avatar placeholder', () => {
591
+ renderWithProviders(<UserMenuLoading />);
592
+
593
+ const avatarPlaceholder = screen.getByRole('button').querySelector('.animate-pulse');
594
+ expect(avatarPlaceholder).toBeInTheDocument();
595
+ });
596
+
597
+ it('renders with proper accessibility attributes', () => {
598
+ renderWithProviders(<UserMenuLoading />);
599
+
600
+ expect(screen.getByRole('status')).toBeInTheDocument();
601
+ expect(screen.getByLabelText('Loading user menu')).toBeInTheDocument();
602
+ });
603
+
604
+ it('renders with aria-live polite', () => {
605
+ renderWithProviders(<UserMenuLoading />);
606
+
607
+ const statusElement = screen.getByRole('status');
608
+ expect(statusElement).toHaveAttribute('aria-live', 'polite');
609
+ });
610
+ });
611
+
612
+ describe('Accessibility', () => {
613
+ it('is not focusable when disabled', () => {
614
+ renderWithProviders(<UserMenuLoading />);
615
+
616
+ const button = screen.getByRole('button');
617
+ expect(button).toBeDisabled();
618
+ });
619
+
620
+ it('provides loading status to screen readers', () => {
621
+ renderWithProviders(<UserMenuLoading />);
622
+
623
+ expect(screen.getByLabelText('Loading user menu')).toBeInTheDocument();
624
+ });
625
+ });
626
+ });
627
+
628
+ describe('UserMenu Component Integration', () => {
629
+ beforeEach(() => {
630
+ vi.clearAllMocks();
631
+ });
632
+
633
+ describe('Complex Scenarios', () => {
634
+ it('handles rapid menu interactions', async () => {
635
+ const user = createMockUser();
636
+ const handleSignOut = vi.fn();
637
+ const userEventInstance = userEvent.setup();
638
+
639
+ renderWithProviders(<UserMenu user={user} onSignOut={handleSignOut} />);
640
+
641
+ const trigger = screen.getByRole('button', { name: 'Test User' });
642
+
643
+ // Rapid clicks
644
+ for (let i = 0; i < 3; i++) {
645
+ await userEventInstance.click(trigger);
646
+ await userEventInstance.click(trigger);
647
+ }
648
+
649
+ expect(screen.getByTestId('select-content')).toBeInTheDocument();
650
+ });
651
+
652
+ it('works with multiple UserMenu instances', () => {
653
+ const user1 = createMockUser({ user_metadata: { display_name: 'User 1' } });
654
+ const user2 = createMockUser({ user_metadata: { display_name: 'User 2' } });
655
+
656
+ renderWithProviders(
657
+ <div>
658
+ <UserMenu user={user1} />
659
+ <UserMenu user={user2} />
660
+ </div>
661
+ );
662
+
663
+ expect(screen.getByRole('button', { name: 'User 1' })).toBeInTheDocument();
664
+ expect(screen.getByRole('button', { name: 'User 2' })).toBeInTheDocument();
665
+ });
666
+
667
+ it('handles async operations correctly', async () => {
668
+ const user = createMockUser();
669
+ const handleSignOut = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
670
+ const userEventInstance = userEvent.setup();
671
+
672
+ renderWithProviders(<UserMenu user={user} onSignOut={handleSignOut} />);
673
+
674
+ const trigger = screen.getByRole('button', { name: 'Test User' });
675
+ await userEventInstance.click(trigger);
676
+
677
+ const signOutButton = screen.getByTestId('select-item-sign-out');
678
+ await userEventInstance.click(signOutButton);
679
+
680
+ expect(handleSignOut).toHaveBeenCalledTimes(1);
681
+ });
682
+ });
683
+
684
+ describe('Error Boundaries', () => {
685
+ it('handles component errors gracefully', () => {
686
+ const user = createMockUser();
687
+
688
+ // This should not throw an error
689
+ expect(() => {
690
+ renderWithProviders(<UserMenu user={user} />);
691
+ }).not.toThrow();
692
+ });
693
+
694
+ it('handles missing callbacks gracefully', () => {
695
+ const user = createMockUser();
696
+
697
+ expect(() => {
698
+ renderWithProviders(<UserMenu user={user} />);
699
+ }).not.toThrow();
700
+ });
701
+ });
702
+ });