@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,621 @@
1
+ /**
2
+ * @file PasswordChangeForm Component Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/PasswordReset
5
+ * @since 0.1.0
6
+ *
7
+ * Comprehensive test suite for the PasswordChangeForm component.
8
+ * Tests cover rendering, form submission, validation, loading states,
9
+ * error handling, and accessibility compliance.
10
+ */
11
+
12
+ import React from 'react';
13
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
14
+ import userEvent from '@testing-library/user-event';
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import { PasswordChangeForm, PasswordChangeFormProps, PasswordChangeFormValues } from './PasswordChangeForm';
17
+
18
+ // Mock child components
19
+ vi.mock('../Button/Button', () => ({
20
+ Button: ({ children, onClick, disabled, className, type, ...props }: any) => (
21
+ <button
22
+ onClick={onClick}
23
+ disabled={disabled}
24
+ className={className}
25
+ type={type}
26
+ {...props}
27
+ >
28
+ {children}
29
+ </button>
30
+ )
31
+ }));
32
+
33
+ vi.mock('../Input/Input', () => ({
34
+ Input: ({ value, onChange, placeholder, disabled, required, type, id, ...props }: any) => (
35
+ <input
36
+ id={id}
37
+ type={type}
38
+ value={value}
39
+ onChange={onChange}
40
+ placeholder={placeholder}
41
+ disabled={disabled}
42
+ required={required}
43
+ {...props}
44
+ />
45
+ )
46
+ }));
47
+
48
+ vi.mock('../Label', () => ({
49
+ Label: ({ children, htmlFor, ...props }: any) => (
50
+ <label htmlFor={htmlFor} {...props}>
51
+ {children}
52
+ </label>
53
+ )
54
+ }));
55
+
56
+ describe('PasswordChangeForm', () => {
57
+ const mockOnSubmit = vi.fn();
58
+ const defaultProps: PasswordChangeFormProps = {
59
+ onSubmit: mockOnSubmit
60
+ };
61
+
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.clearAllMocks();
68
+ });
69
+
70
+ describe('Rendering', () => {
71
+ it('renders password change form with all elements', () => {
72
+ render(<PasswordChangeForm {...defaultProps} />);
73
+
74
+ expect(screen.getByLabelText('New Password')).toBeInTheDocument();
75
+ expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument();
76
+ expect(screen.getByRole('button', { name: 'Change Password' })).toBeInTheDocument();
77
+ });
78
+
79
+ it('renders with custom className', () => {
80
+ const customClass = 'custom-password-change-form';
81
+ const { container } = render(<PasswordChangeForm {...defaultProps} className={customClass} />);
82
+
83
+ const form = container.querySelector('form');
84
+ expect(form).toHaveClass(customClass);
85
+ });
86
+
87
+ it('has proper form structure and accessibility', () => {
88
+ render(<PasswordChangeForm {...defaultProps} />);
89
+
90
+ const newPasswordInput = screen.getByLabelText('New Password');
91
+ expect(newPasswordInput).toHaveAttribute('type', 'password');
92
+ expect(newPasswordInput).toHaveAttribute('required');
93
+ expect(newPasswordInput).toHaveAttribute('id', 'new-password');
94
+
95
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
96
+ expect(confirmPasswordInput).toHaveAttribute('type', 'password');
97
+ expect(confirmPasswordInput).toHaveAttribute('required');
98
+ expect(confirmPasswordInput).toHaveAttribute('id', 'confirm-password');
99
+
100
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
101
+ expect(submitButton).toHaveAttribute('type', 'submit');
102
+ });
103
+ });
104
+
105
+ describe('Form Interaction', () => {
106
+ it('updates new password input value when user types', async () => {
107
+ const user = userEvent.setup();
108
+ render(<PasswordChangeForm {...defaultProps} />);
109
+
110
+ const newPasswordInput = screen.getByLabelText('New Password');
111
+ await user.type(newPasswordInput, 'newpassword123');
112
+
113
+ expect(newPasswordInput).toHaveValue('newpassword123');
114
+ });
115
+
116
+ it('updates confirm password input value when user types', async () => {
117
+ const user = userEvent.setup();
118
+ render(<PasswordChangeForm {...defaultProps} />);
119
+
120
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
121
+ await user.type(confirmPasswordInput, 'newpassword123');
122
+
123
+ expect(confirmPasswordInput).toHaveValue('newpassword123');
124
+ });
125
+
126
+ it('enables submit button when both passwords are provided', async () => {
127
+ const user = userEvent.setup();
128
+ render(<PasswordChangeForm {...defaultProps} />);
129
+
130
+ const newPasswordInput = screen.getByLabelText('New Password');
131
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
132
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
133
+
134
+ expect(submitButton).toBeDisabled();
135
+
136
+ await user.type(newPasswordInput, 'newpassword123');
137
+ await user.type(confirmPasswordInput, 'newpassword123');
138
+
139
+ expect(submitButton).toBeEnabled();
140
+ });
141
+
142
+ it('disables submit button when new password is empty', async () => {
143
+ const user = userEvent.setup();
144
+ render(<PasswordChangeForm {...defaultProps} />);
145
+
146
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
147
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
148
+
149
+ await user.type(confirmPasswordInput, 'newpassword123');
150
+
151
+ expect(submitButton).toBeDisabled();
152
+ });
153
+
154
+ it('disables submit button when confirm password is empty', async () => {
155
+ const user = userEvent.setup();
156
+ render(<PasswordChangeForm {...defaultProps} />);
157
+
158
+ const newPasswordInput = screen.getByLabelText('New Password');
159
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
160
+
161
+ await user.type(newPasswordInput, 'newpassword123');
162
+
163
+ expect(submitButton).toBeDisabled();
164
+ });
165
+ });
166
+
167
+ describe('Form Validation', () => {
168
+ it('shows error when password is too short', async () => {
169
+ const user = userEvent.setup();
170
+ render(<PasswordChangeForm {...defaultProps} />);
171
+
172
+ const newPasswordInput = screen.getByLabelText('New Password');
173
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
174
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
175
+
176
+ await user.type(newPasswordInput, 'short');
177
+ await user.type(confirmPasswordInput, 'short');
178
+ await user.click(submitButton);
179
+
180
+ expect(screen.getByText('Password must be at least 8 characters.')).toBeInTheDocument();
181
+ expect(mockOnSubmit).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it('shows error when passwords do not match', async () => {
185
+ const user = userEvent.setup();
186
+ render(<PasswordChangeForm {...defaultProps} />);
187
+
188
+ const newPasswordInput = screen.getByLabelText('New Password');
189
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
190
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
191
+
192
+ await user.type(newPasswordInput, 'newpassword123');
193
+ await user.type(confirmPasswordInput, 'differentpassword');
194
+ await user.click(submitButton);
195
+
196
+ expect(screen.getByText('Passwords do not match.')).toBeInTheDocument();
197
+ expect(mockOnSubmit).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('validates password length before checking match', async () => {
201
+ const user = userEvent.setup();
202
+ render(<PasswordChangeForm {...defaultProps} />);
203
+
204
+ const newPasswordInput = screen.getByLabelText('New Password');
205
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
206
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
207
+
208
+ await user.type(newPasswordInput, 'short');
209
+ await user.type(confirmPasswordInput, 'short');
210
+ await user.click(submitButton);
211
+
212
+ expect(screen.getByText('Password must be at least 8 characters.')).toBeInTheDocument();
213
+ expect(screen.queryByText('Passwords do not match.')).not.toBeInTheDocument();
214
+ });
215
+
216
+ it('clears error when form is resubmitted with valid data', async () => {
217
+ const user = userEvent.setup();
218
+ render(<PasswordChangeForm {...defaultProps} />);
219
+
220
+ const newPasswordInput = screen.getByLabelText('New Password');
221
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
222
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
223
+
224
+ // First submit with invalid data
225
+ await user.type(newPasswordInput, 'short');
226
+ await user.type(confirmPasswordInput, 'short');
227
+ await user.click(submitButton);
228
+
229
+ expect(screen.getByText('Password must be at least 8 characters.')).toBeInTheDocument();
230
+
231
+ // Clear and resubmit with valid data
232
+ await user.clear(newPasswordInput);
233
+ await user.clear(confirmPasswordInput);
234
+ await user.type(newPasswordInput, 'newpassword123');
235
+ await user.type(confirmPasswordInput, 'newpassword123');
236
+ await user.click(submitButton);
237
+
238
+ expect(screen.queryByText('Password must be at least 8 characters.')).not.toBeInTheDocument();
239
+ });
240
+ });
241
+
242
+ describe('Form Submission', () => {
243
+ it('calls onSubmit with correct values when form is submitted', async () => {
244
+ const user = userEvent.setup();
245
+ mockOnSubmit.mockResolvedValue({});
246
+
247
+ render(<PasswordChangeForm {...defaultProps} />);
248
+
249
+ const newPasswordInput = screen.getByLabelText('New Password');
250
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
251
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
252
+
253
+ await user.type(newPasswordInput, 'newpassword123');
254
+ await user.type(confirmPasswordInput, 'newpassword123');
255
+ await user.click(submitButton);
256
+
257
+ expect(mockOnSubmit).toHaveBeenCalledWith({
258
+ newPassword: 'newpassword123',
259
+ confirmPassword: 'newpassword123'
260
+ });
261
+ });
262
+
263
+ it('prevents form submission when validation fails', async () => {
264
+ const user = userEvent.setup();
265
+ render(<PasswordChangeForm {...defaultProps} />);
266
+
267
+ const newPasswordInput = screen.getByLabelText('New Password');
268
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
269
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
270
+
271
+ await user.type(newPasswordInput, 'short');
272
+ await user.type(confirmPasswordInput, 'short');
273
+ await user.click(submitButton);
274
+
275
+ expect(mockOnSubmit).not.toHaveBeenCalled();
276
+ });
277
+ });
278
+
279
+ describe('Loading States', () => {
280
+ it('shows loading state during form submission', async () => {
281
+ const user = userEvent.setup();
282
+ mockOnSubmit.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
283
+
284
+ render(<PasswordChangeForm {...defaultProps} />);
285
+
286
+ const newPasswordInput = screen.getByLabelText('New Password');
287
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
288
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
289
+
290
+ await user.type(newPasswordInput, 'newpassword123');
291
+ await user.type(confirmPasswordInput, 'newpassword123');
292
+ await user.click(submitButton);
293
+
294
+ expect(screen.getByRole('button', { name: 'Changing...' })).toBeInTheDocument();
295
+ expect(screen.getByRole('button', { name: 'Changing...' })).toBeDisabled();
296
+ });
297
+
298
+ it('disables inputs during loading', async () => {
299
+ const user = userEvent.setup();
300
+ mockOnSubmit.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
301
+
302
+ render(<PasswordChangeForm {...defaultProps} />);
303
+
304
+ const newPasswordInput = screen.getByLabelText('New Password');
305
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
306
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
307
+
308
+ await user.type(newPasswordInput, 'newpassword123');
309
+ await user.type(confirmPasswordInput, 'newpassword123');
310
+ await user.click(submitButton);
311
+
312
+ expect(newPasswordInput).toBeDisabled();
313
+ expect(confirmPasswordInput).toBeDisabled();
314
+ });
315
+
316
+ it('resets loading state after successful submission', async () => {
317
+ const user = userEvent.setup();
318
+ mockOnSubmit.mockResolvedValue({});
319
+
320
+ render(<PasswordChangeForm {...defaultProps} />);
321
+
322
+ const newPasswordInput = screen.getByLabelText('New Password');
323
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
324
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
325
+
326
+ await user.type(newPasswordInput, 'newpassword123');
327
+ await user.type(confirmPasswordInput, 'newpassword123');
328
+ await user.click(submitButton);
329
+
330
+ await waitFor(() => {
331
+ expect(screen.getByRole('button', { name: 'Change Password' })).toBeInTheDocument();
332
+ expect(screen.getByRole('button', { name: 'Change Password' })).toBeEnabled();
333
+ });
334
+ });
335
+
336
+ it('resets loading state after failed submission', async () => {
337
+ const user = userEvent.setup();
338
+ mockOnSubmit.mockResolvedValue({ error: { message: 'Test error' } });
339
+
340
+ render(<PasswordChangeForm {...defaultProps} />);
341
+
342
+ const newPasswordInput = screen.getByLabelText('New Password');
343
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
344
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
345
+
346
+ await user.type(newPasswordInput, 'newpassword123');
347
+ await user.type(confirmPasswordInput, 'newpassword123');
348
+ await user.click(submitButton);
349
+
350
+ await waitFor(() => {
351
+ expect(screen.getByRole('button', { name: 'Change Password' })).toBeInTheDocument();
352
+ expect(screen.getByRole('button', { name: 'Change Password' })).toBeEnabled();
353
+ });
354
+ });
355
+ });
356
+
357
+ describe('Error Handling', () => {
358
+ it('displays error message when onSubmit returns error', async () => {
359
+ const user = userEvent.setup();
360
+ const errorMessage = 'Password change failed';
361
+ mockOnSubmit.mockResolvedValue({ error: { message: errorMessage } });
362
+
363
+ render(<PasswordChangeForm {...defaultProps} />);
364
+
365
+ const newPasswordInput = screen.getByLabelText('New Password');
366
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
367
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
368
+
369
+ await user.type(newPasswordInput, 'newpassword123');
370
+ await user.type(confirmPasswordInput, 'newpassword123');
371
+ await user.click(submitButton);
372
+
373
+ await waitFor(() => {
374
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
375
+ expect(screen.getByText(errorMessage)).toHaveAttribute('role', 'alert');
376
+ });
377
+ });
378
+
379
+ it('displays error message when onSubmit throws exception', async () => {
380
+ const user = userEvent.setup();
381
+ const errorMessage = 'Network error';
382
+ mockOnSubmit.mockRejectedValue(new Error(errorMessage));
383
+
384
+ render(<PasswordChangeForm {...defaultProps} />);
385
+
386
+ const newPasswordInput = screen.getByLabelText('New Password');
387
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
388
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
389
+
390
+ await user.type(newPasswordInput, 'newpassword123');
391
+ await user.type(confirmPasswordInput, 'newpassword123');
392
+ await user.click(submitButton);
393
+
394
+ await waitFor(() => {
395
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
396
+ expect(screen.getByText(errorMessage)).toHaveAttribute('role', 'alert');
397
+ });
398
+ });
399
+
400
+ it('handles non-Error exceptions gracefully', async () => {
401
+ const user = userEvent.setup();
402
+ mockOnSubmit.mockRejectedValue('String error');
403
+
404
+ render(<PasswordChangeForm {...defaultProps} />);
405
+
406
+ const newPasswordInput = screen.getByLabelText('New Password');
407
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
408
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
409
+
410
+ await user.type(newPasswordInput, 'newpassword123');
411
+ await user.type(confirmPasswordInput, 'newpassword123');
412
+ await user.click(submitButton);
413
+
414
+ await waitFor(() => {
415
+ expect(screen.getByText('An unexpected error occurred.')).toBeInTheDocument();
416
+ });
417
+ });
418
+
419
+ it('handles error without message property', async () => {
420
+ const user = userEvent.setup();
421
+ mockOnSubmit.mockResolvedValue({ error: { code: 'INVALID_PASSWORD' } });
422
+
423
+ render(<PasswordChangeForm {...defaultProps} />);
424
+
425
+ const newPasswordInput = screen.getByLabelText('New Password');
426
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
427
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
428
+
429
+ await user.type(newPasswordInput, 'newpassword123');
430
+ await user.type(confirmPasswordInput, 'newpassword123');
431
+ await user.click(submitButton);
432
+
433
+ await waitFor(() => {
434
+ expect(screen.getByText('Failed to change password.')).toBeInTheDocument();
435
+ });
436
+ });
437
+
438
+ it('clears error when form is resubmitted', async () => {
439
+ const user = userEvent.setup();
440
+ mockOnSubmit
441
+ .mockResolvedValueOnce({ error: { message: 'First error' } })
442
+ .mockResolvedValueOnce({});
443
+
444
+ render(<PasswordChangeForm {...defaultProps} />);
445
+
446
+ const newPasswordInput = screen.getByLabelText('New Password');
447
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
448
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
449
+
450
+ await user.type(newPasswordInput, 'newpassword123');
451
+ await user.type(confirmPasswordInput, 'newpassword123');
452
+ await user.click(submitButton);
453
+
454
+ await waitFor(() => {
455
+ expect(screen.getByText('First error')).toBeInTheDocument();
456
+ });
457
+
458
+ await user.click(submitButton);
459
+
460
+ await waitFor(() => {
461
+ expect(screen.queryByText('First error')).not.toBeInTheDocument();
462
+ });
463
+ });
464
+ });
465
+
466
+ describe('Accessibility', () => {
467
+ it('has proper form labels and associations', () => {
468
+ render(<PasswordChangeForm {...defaultProps} />);
469
+
470
+ const newPasswordInput = screen.getByLabelText('New Password');
471
+ expect(newPasswordInput).toHaveAttribute('id', 'new-password');
472
+ expect(newPasswordInput).toHaveAttribute('type', 'password');
473
+
474
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
475
+ expect(confirmPasswordInput).toHaveAttribute('id', 'confirm-password');
476
+ expect(confirmPasswordInput).toHaveAttribute('type', 'password');
477
+ });
478
+
479
+ it('announces errors to screen readers', async () => {
480
+ const user = userEvent.setup();
481
+ mockOnSubmit.mockResolvedValue({ error: { message: 'Test error' } });
482
+
483
+ render(<PasswordChangeForm {...defaultProps} />);
484
+
485
+ const newPasswordInput = screen.getByLabelText('New Password');
486
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
487
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
488
+
489
+ await user.type(newPasswordInput, 'newpassword123');
490
+ await user.type(confirmPasswordInput, 'newpassword123');
491
+ await user.click(submitButton);
492
+
493
+ await waitFor(() => {
494
+ const errorElement = screen.getByText('Test error');
495
+ expect(errorElement).toHaveAttribute('role', 'alert');
496
+ });
497
+ });
498
+
499
+ it('maintains focus management during state changes', async () => {
500
+ const user = userEvent.setup();
501
+ mockOnSubmit.mockResolvedValue({});
502
+
503
+ render(<PasswordChangeForm {...defaultProps} />);
504
+
505
+ const newPasswordInput = screen.getByLabelText('New Password');
506
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
507
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
508
+
509
+ await user.type(newPasswordInput, 'newpassword123');
510
+ await user.type(confirmPasswordInput, 'newpassword123');
511
+ await user.click(submitButton);
512
+
513
+ await waitFor(() => {
514
+ expect(screen.getByRole('button', { name: 'Change Password' })).toBeInTheDocument();
515
+ });
516
+ });
517
+ });
518
+
519
+ describe('Edge Cases', () => {
520
+ it('handles empty password values', () => {
521
+ render(<PasswordChangeForm {...defaultProps} />);
522
+
523
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
524
+
525
+ expect(submitButton).toBeDisabled();
526
+ });
527
+
528
+ it('handles whitespace-only passwords', async () => {
529
+ const user = userEvent.setup();
530
+ render(<PasswordChangeForm {...defaultProps} />);
531
+
532
+ const newPasswordInput = screen.getByLabelText('New Password');
533
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
534
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
535
+
536
+ await user.type(newPasswordInput, ' ');
537
+ await user.type(confirmPasswordInput, ' ');
538
+
539
+ // The button should be enabled because the inputs have values (even if whitespace)
540
+ // The validation happens on submit, not on input
541
+ expect(submitButton).toBeEnabled();
542
+ });
543
+
544
+ it('handles very long passwords', async () => {
545
+ const user = userEvent.setup();
546
+ const longPassword = 'a'.repeat(1000);
547
+ mockOnSubmit.mockResolvedValue({});
548
+
549
+ render(<PasswordChangeForm {...defaultProps} />);
550
+
551
+ const newPasswordInput = screen.getByLabelText('New Password');
552
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
553
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
554
+
555
+ await user.type(newPasswordInput, longPassword);
556
+ await user.type(confirmPasswordInput, longPassword);
557
+ await user.click(submitButton);
558
+
559
+ expect(mockOnSubmit).toHaveBeenCalledWith({
560
+ newPassword: longPassword,
561
+ confirmPassword: longPassword
562
+ });
563
+ });
564
+
565
+ it('handles special characters in passwords', async () => {
566
+ const user = userEvent.setup();
567
+ const specialPassword = 'P@ssw0rd!@#$%^&*()_+-=';
568
+ mockOnSubmit.mockResolvedValue({});
569
+
570
+ render(<PasswordChangeForm {...defaultProps} />);
571
+
572
+ const newPasswordInput = screen.getByLabelText('New Password');
573
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
574
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
575
+
576
+ await user.type(newPasswordInput, specialPassword);
577
+ await user.type(confirmPasswordInput, specialPassword);
578
+ await user.click(submitButton);
579
+
580
+ expect(mockOnSubmit).toHaveBeenCalledWith({
581
+ newPassword: specialPassword,
582
+ confirmPassword: specialPassword
583
+ });
584
+ });
585
+
586
+ it('handles unicode characters in passwords', async () => {
587
+ const user = userEvent.setup();
588
+ const unicodePassword = 'pássw0rd_测试_пароль';
589
+ mockOnSubmit.mockResolvedValue({});
590
+
591
+ render(<PasswordChangeForm {...defaultProps} />);
592
+
593
+ const newPasswordInput = screen.getByLabelText('New Password');
594
+ const confirmPasswordInput = screen.getByLabelText('Confirm Password');
595
+ const submitButton = screen.getByRole('button', { name: 'Change Password' });
596
+
597
+ await user.type(newPasswordInput, unicodePassword);
598
+ await user.type(confirmPasswordInput, unicodePassword);
599
+ await user.click(submitButton);
600
+
601
+ expect(mockOnSubmit).toHaveBeenCalledWith({
602
+ newPassword: unicodePassword,
603
+ confirmPassword: unicodePassword
604
+ });
605
+ });
606
+ });
607
+
608
+ describe('Performance', () => {
609
+ it('handles rapid input changes efficiently', async () => {
610
+ const user = userEvent.setup();
611
+ render(<PasswordChangeForm {...defaultProps} />);
612
+
613
+ const newPasswordInput = screen.getByLabelText('New Password');
614
+
615
+ // Type rapidly to test performance
616
+ await user.type(newPasswordInput, 'testpassword123');
617
+
618
+ expect(newPasswordInput).toHaveValue('testpassword123');
619
+ });
620
+ });
621
+ });