diff --git a/app/src/components/modals/account/DeleteAccountModal.test.tsx b/app/src/components/modals/account/DeleteAccountModal.test.tsx new file mode 100644 index 0000000..af4f1f2 --- /dev/null +++ b/app/src/components/modals/account/DeleteAccountModal.test.tsx @@ -0,0 +1,772 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import DeleteAccountModal from './DeleteAccountModal'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DeleteAccountModal', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + mockOnClose.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when opened', () => { + render( + + ); + + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + expect( + screen.getByText('Warning: This action cannot be undone') + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Please enter your password to confirm account deletion.' + ) + ).toBeInTheDocument(); + expect( + screen.getByTestId('delete-account-password-input') + ).toBeInTheDocument(); + expect(screen.getByTestId('cancel-delete-button')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-delete-button')).toBeInTheDocument(); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Delete Account')).not.toBeInTheDocument(); + }); + + it('calls onClose when modal is closed via cancel button', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Form Interaction', () => { + it('updates password input when typed', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + fireEvent.change(passwordInput, { target: { value: 'testpassword123' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('testpassword123'); + }); + + it('handles form submission with valid password', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'validpassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('validpassword'); + }); + }); + + it('prevents submission with empty password', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + // Should not call the function with empty password + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('clears input after successful submission', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); + }); + + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + }); + + describe('Modal Actions', () => { + it('has cancel and delete buttons', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + const cancelButton = screen.getByTestId('cancel-delete-button'); + + expect(deleteButton).toBeInTheDocument(); + expect(cancelButton).toBeInTheDocument(); + + expect(deleteButton).toHaveRole('button'); + expect(cancelButton).toHaveRole('button'); + }); + + it('has proper button styling and colors', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + const cancelButton = screen.getByTestId('cancel-delete-button'); + + expect(deleteButton).toHaveTextContent('Delete'); + expect(cancelButton).toHaveTextContent('Cancel'); + }); + + it('closes modal when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('calls onConfirm when delete button is clicked with valid input', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'mypassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + expect(mockOnConfirm).toHaveBeenCalledWith('mypassword'); + }); + }); + }); + + describe('Warning Display', () => { + it('displays the warning message prominently', () => { + render( + + ); + + const warningElement = screen.getByText( + 'Warning: This action cannot be undone' + ); + expect(warningElement).toBeInTheDocument(); + }); + + it('displays the confirmation instructions', () => { + render( + + ); + + expect( + screen.getByText( + 'Please enter your password to confirm account deletion.' + ) + ).toBeInTheDocument(); + }); + }); + + describe('Password Validation', () => { + it('handles various password formats', async () => { + const passwords = [ + 'simple123', + 'Complex!Password@123', + 'spaces in password', + '12345', + 'very-long-password-with-many-characters-and-symbols!@#$%^&*()', + ]; + + for (const password of passwords) { + const { unmount } = render( + + ); + + const passwordInput = screen.getByTestId( + 'delete-account-password-input' + ); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: password } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(password); + }); + + unmount(); + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + } + }); + + it('handles unicode characters in passwords', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + const unicodePassword = 'パスワード123'; + fireEvent.change(passwordInput, { target: { value: unicodePassword } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(unicodePassword); + }); + }); + + it('handles whitespace-only passwords', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: ' ' } }); + fireEvent.click(deleteButton); + + // Should not call confirm function for whitespace-only password + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('handles deletion errors gracefully', async () => { + mockOnConfirm.mockRejectedValue(new Error('Account deletion failed')); + + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword'); + }); + + // Modal should handle the error gracefully (not crash) + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + }); + + it('does not clear input when deletion fails', async () => { + mockOnConfirm.mockRejectedValue(new Error('Invalid password')); + + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); + }); + + // Input should retain value when deletion fails + expect((passwordInput as HTMLInputElement).value).toBe('testpassword'); + }); + + it('handles authentication errors', async () => { + mockOnConfirm.mockRejectedValue(new Error('Authentication failed')); + + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'authtest' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('authtest'); + }); + + // Should not crash the component + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and structure', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + expect(passwordInput).toBeInTheDocument(); + expect(passwordInput.tagName).toBe('INPUT'); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('has proper button roles', () => { + render( + + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); // Cancel and Delete buttons + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const deleteButton = screen.getByRole('button', { + name: /delete/i, + }); + + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + + // Check that the input is focusable (not disabled or readonly) + expect(passwordInput).not.toHaveAttribute('disabled'); + expect(passwordInput).not.toHaveAttribute('readonly'); + + // Check that the input can receive keyboard events + fireEvent.keyDown(passwordInput, { key: 'a' }); + fireEvent.change(passwordInput, { target: { value: 'test' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('test'); + + // Verify the input is accessible via keyboard navigation + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).toHaveAccessibleName(); // Has proper label + }); + + it('has proper modal structure', () => { + render( + + ); + + // Modal should have proper title + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + + // Should have form elements + expect( + screen.getByTestId('delete-account-password-input') + ).toBeInTheDocument(); + }); + + it('has proper warning styling and visibility', () => { + render( + + ); + + const warningText = screen.getByText( + 'Warning: This action cannot be undone' + ); + expect(warningText).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onConfirm prop correctly', async () => { + const customMockConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'custompassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(customMockConfirm).toHaveBeenCalledWith('custompassword'); + }); + }); + + it('accepts and uses onClose prop correctly', () => { + const customMockClose = vi.fn(); + + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + fireEvent.click(cancelButton); + + expect(customMockClose).toHaveBeenCalled(); + }); + + it('handles function props correctly', () => { + const testOnConfirm = vi.fn(); + const testOnClose = vi.fn(); + + expect(() => { + render( + + ); + }).not.toThrow(); + + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + }); + + it('handles opened prop correctly', () => { + const { rerender } = render( + + ); + + // Should not be visible when opened is false + expect(screen.queryByText('Delete Account')).not.toBeInTheDocument(); + + rerender( + + + + ); + + // Should be visible when opened is true + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + }); + }); + + describe('User Interaction Flow', () => { + it('completes full deletion confirmation flow successfully', async () => { + render( + + ); + + // 1. Modal opens and shows warning + expect( + screen.getByText('Warning: This action cannot be undone') + ).toBeInTheDocument(); + + // 2. User types password + const passwordInput = screen.getByTestId('delete-account-password-input'); + fireEvent.change(passwordInput, { target: { value: 'userpassword' } }); + + // 3. User clicks delete + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + // 4. Confirmation function is called + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('userpassword'); + }); + + // 5. Input is cleared + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('allows user to cancel account deletion', () => { + render( + + ); + + // User types password but then cancels + const passwordInput = screen.getByTestId('delete-account-password-input'); + fireEvent.change(passwordInput, { + target: { value: 'cancelledaction' }, + }); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + fireEvent.click(cancelButton); + + // Should close modal without calling confirm function + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('handles multiple rapid clicks gracefully', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { target: { value: 'rapidtest' } }); + + // Rapidly click multiple times - should not crash + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + + // Verify component is still functional + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest'); + }); + + it('prevents accidental deletion with empty password', () => { + render( + + ); + + // User immediately clicks delete without entering password + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + // Should not proceed with deletion + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + }); + }); + + describe('Security Considerations', () => { + it('masks password input properly', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('clears password from memory after successful deletion', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.change(passwordInput, { + target: { value: 'sensitivepassword' }, + }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('sensitivepassword'); + }); + + // Password should be cleared from the input + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('requires explicit password confirmation', () => { + render( + + ); + + // Should require password input + const passwordInput = screen.getByTestId('delete-account-password-input'); + expect(passwordInput).toHaveAttribute('required'); + + // Should show clear warning + expect( + screen.getByText('Warning: This action cannot be undone') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/account/DeleteAccountModal.tsx b/app/src/components/modals/account/DeleteAccountModal.tsx index d3e2b81..668704d 100644 --- a/app/src/components/modals/account/DeleteAccountModal.tsx +++ b/app/src/components/modals/account/DeleteAccountModal.tsx @@ -21,6 +21,20 @@ const DeleteAccountModal: React.FC = ({ }) => { const [password, setPassword] = useState(''); + const handleConfirm = async (): Promise => { + const trimmedPassword = password.trim(); + if (!trimmedPassword) { + return; + } + try { + await onConfirm(trimmedPassword); + setPassword(''); + } catch (error) { + // Keep password in case of error + console.error('Error confirming password:', error); + } + }; + return ( = ({ diff --git a/app/src/components/modals/account/EmailPasswordModal.test.tsx b/app/src/components/modals/account/EmailPasswordModal.test.tsx new file mode 100644 index 0000000..3e8103c --- /dev/null +++ b/app/src/components/modals/account/EmailPasswordModal.test.tsx @@ -0,0 +1,651 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import EmailPasswordModal from './EmailPasswordModal'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('EmailPasswordModal', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + const testEmail = 'newemail@example.com'; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + mockOnClose.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when opened', () => { + render( + + ); + + expect(screen.getByText('Confirm Password')).toBeInTheDocument(); + expect( + screen.getByText( + `Please enter your password to confirm changing your email to: ${testEmail}` + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('email-password-input')).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-email-password-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-email-password-button') + ).toBeInTheDocument(); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Confirm Password')).not.toBeInTheDocument(); + }); + }); + + describe('Form Interaction', () => { + it('updates password input when typed', () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { target: { value: 'testpassword123' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('testpassword123'); + }); + + it('handles form submission with valid password', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'validpassword' } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('validpassword'); + }); + }); + + it('prevents submission with empty password', () => { + render( + + ); + + const confirmButton = screen.getByTestId('confirm-email-password-button'); + fireEvent.click(confirmButton); + + // Should not call the function with empty password + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('clears input after successful submission', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); + }); + + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + }); + + describe('Modal Actions', () => { + it('has cancel and confirm buttons', () => { + render( + + ); + + const confirmButton = screen.getByTestId('confirm-email-password-button'); + const cancelButton = screen.getByTestId('cancel-email-password-button'); + + expect(confirmButton).toBeInTheDocument(); + expect(cancelButton).toBeInTheDocument(); + + expect(confirmButton).toHaveRole('button'); + expect(cancelButton).toHaveRole('button'); + }); + + it('closes modal when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-email-password-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('calls onConfirm when confirm button is clicked with valid input', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'mypassword' } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + expect(mockOnConfirm).toHaveBeenCalledWith('mypassword'); + }); + }); + }); + + describe('Email Display', () => { + it('displays the correct email in the confirmation message', () => { + const customEmail = 'user@custom.com'; + render( + + ); + + expect( + screen.getByText( + `Please enter your password to confirm changing your email to: ${customEmail}` + ) + ).toBeInTheDocument(); + }); + + it('handles different email formats', () => { + const emailFormats = [ + 'simple@example.com', + 'user.name@example.com', + 'user+tag@example.com', + 'very.long.email.address@domain.co.uk', + ]; + + emailFormats.forEach((email) => { + const { unmount } = render( + + ); + + expect( + screen.getByText( + `Please enter your password to confirm changing your email to: ${email}` + ) + ).toBeInTheDocument(); + + unmount(); + }); + }); + + it('handles empty email string', () => { + render( + + ); + + expect(screen.getByTestId('email-password-message')).toHaveTextContent( + 'Please enter your password to confirm changing your email to:' + ); + }); + }); + + describe('Password Validation', () => { + it('handles various password formats', async () => { + const passwords = [ + 'simple123', + 'Complex!Password@123', + 'spaces in password', + '12345', + 'very-long-password-with-many-characters-and-symbols!@#$%^&*()', + ]; + + for (const password of passwords) { + const { unmount } = render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId( + 'confirm-email-password-button' + ); + + fireEvent.change(passwordInput, { target: { value: password } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(password); + }); + + unmount(); + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + } + }); + + it('handles unicode characters in passwords', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + const unicodePassword = 'パスワード123'; + fireEvent.change(passwordInput, { target: { value: unicodePassword } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(unicodePassword); + }); + }); + + it('trims whitespace from passwords', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { + target: { value: ' password123 ' }, + }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('password123'); + }); + }); + }); + + describe('Error Handling', () => { + it('handles confirmation errors gracefully', async () => { + mockOnConfirm.mockRejectedValue(new Error('Authentication failed')); + + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword'); + }); + + // Modal should handle the error gracefully (not crash) + expect(screen.getByText('Confirm Password')).toBeInTheDocument(); + }); + + it('does not clear input when confirmation fails', async () => { + mockOnConfirm.mockRejectedValue(new Error('Invalid password')); + + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); + }); + + // Input should retain value when confirmation fails + expect((passwordInput as HTMLInputElement).value).toBe('testpassword'); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and structure', () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + expect(passwordInput).toBeInTheDocument(); + expect(passwordInput.tagName).toBe('INPUT'); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('has proper button roles', () => { + render( + + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); // Cancel and Confirm buttons + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const confirmButton = screen.getByRole('button', { name: /confirm/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(confirmButton).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + + // Check that the input is focusable (not disabled or readonly) + expect(passwordInput).not.toHaveAttribute('disabled'); + expect(passwordInput).not.toHaveAttribute('readonly'); + + // Check that the input can receive keyboard events + fireEvent.keyDown(passwordInput, { key: 'a' }); + fireEvent.change(passwordInput, { target: { value: 'test' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('test'); + + // Verify the input is accessible via keyboard navigation + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).toHaveAccessibleName(); // Has proper label + }); + + it('has proper modal structure', () => { + render( + + ); + + // Modal should have proper title + expect(screen.getByText('Confirm Password')).toBeInTheDocument(); + + // Should have form elements + expect(screen.getByTestId('email-password-input')).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onConfirm prop correctly', async () => { + const customMockConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'custompassword' } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(customMockConfirm).toHaveBeenCalledWith('custompassword'); + }); + }); + + it('accepts and uses onClose prop correctly', () => { + const customMockClose = vi.fn(); + + render( + + ); + + const cancelButton = screen.getByTestId('cancel-email-password-button'); + fireEvent.click(cancelButton); + + expect(customMockClose).toHaveBeenCalled(); + }); + + it('handles function props correctly', () => { + const testOnConfirm = vi.fn(); + const testOnClose = vi.fn(); + + expect(() => { + render( + + ); + }).not.toThrow(); + + expect(screen.getByText('Confirm Password')).toBeInTheDocument(); + }); + }); + + describe('User Interaction Flow', () => { + it('completes full confirmation flow successfully', async () => { + render( + + ); + + // 1. Modal opens and shows email change confirmation + expect( + screen.getByText( + `Please enter your password to confirm changing your email to: ${testEmail}` + ) + ).toBeInTheDocument(); + + // 2. User types password + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { target: { value: 'userpassword' } }); + + // 3. User clicks confirm + const confirmButton = screen.getByTestId('confirm-email-password-button'); + fireEvent.click(confirmButton); + + // 4. Confirmation function is called + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('userpassword'); + }); + + // 5. Input is cleared + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('allows user to cancel email change', () => { + render( + + ); + + // User types password but then cancels + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { + target: { value: 'cancelleddaction' }, + }); + + const cancelButton = screen.getByTestId('cancel-email-password-button'); + fireEvent.click(cancelButton); + + // Should close modal without calling confirm function + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('handles multiple rapid clicks gracefully', () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'rapidtest' } }); + + // Rapidly click multiple times - should not crash + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); + + // Verify component is still functional + expect(screen.getByText('Confirm Password')).toBeInTheDocument(); + expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest'); + }); + }); +}); diff --git a/app/src/components/modals/account/EmailPasswordModal.tsx b/app/src/components/modals/account/EmailPasswordModal.tsx index e82c92d..66f3ac8 100644 --- a/app/src/components/modals/account/EmailPasswordModal.tsx +++ b/app/src/components/modals/account/EmailPasswordModal.tsx @@ -11,7 +11,7 @@ import { interface EmailPasswordModalProps { opened: boolean; onClose: () => void; - onConfirm: (password: string) => Promise; + onConfirm: (password: string) => Promise; email: string; } @@ -23,6 +23,27 @@ const EmailPasswordModal: React.FC = ({ }) => { const [password, setPassword] = useState(''); + async function handleConfirm(): Promise { + const trimmedPassword = password.trim(); + if (!trimmedPassword) { + return; + } + try { + await onConfirm(trimmedPassword); + setPassword(''); + } catch (error) { + // Keep password in case of error + console.error('Error confirming password:', error); + } + } + + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleConfirm(); + } + }; + return ( = ({ size="sm" > - - Please enter your password to confirm changing your email to: {email} + + {`Please enter your password to confirm changing your email to: ${email}`} setPassword(e.currentTarget.value)} required /> @@ -52,11 +74,9 @@ const EmailPasswordModal: React.FC = ({ Cancel diff --git a/app/src/components/modals/file/CreateFileModal.tsx b/app/src/components/modals/file/CreateFileModal.tsx index 655a736..499af0f 100644 --- a/app/src/components/modals/file/CreateFileModal.tsx +++ b/app/src/components/modals/file/CreateFileModal.tsx @@ -56,6 +56,7 @@ const CreateFileModal: React.FC = ({ onCreateFile }) => { diff --git a/app/src/components/modals/git/CommitMessageModal.tsx b/app/src/components/modals/git/CommitMessageModal.tsx index db9c25b..7eab8cc 100644 --- a/app/src/components/modals/git/CommitMessageModal.tsx +++ b/app/src/components/modals/git/CommitMessageModal.tsx @@ -60,6 +60,7 @@ const CommitMessageModal: React.FC = ({ diff --git a/app/src/components/settings/account/AccountSettings.tsx b/app/src/components/settings/account/AccountSettings.tsx index 5464429..5538ddb 100644 --- a/app/src/components/settings/account/AccountSettings.tsx +++ b/app/src/components/settings/account/AccountSettings.tsx @@ -153,7 +153,7 @@ const AccountSettings: React.FC = ({ } }; - const handleEmailConfirm = async (password: string): Promise => { + const handleEmailConfirm = async (password: string): Promise => { const updates: UserProfileSettings = { ...state.localSettings, currentPassword: password, @@ -181,6 +181,11 @@ const AccountSettings: React.FC = ({ dispatch({ type: SettingsActionType.MARK_SAVED }); setEmailModalOpened(false); onClose(); + return true; + } else { + // TODO: Handle errors appropriately + // notifications.show({... + return false; } };