From bc17005eca11b576f44ce45d12a3952ac4ce15b9 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 1 Jun 2025 13:58:06 +0200 Subject: [PATCH] Add DeleteUserModal and EditUserModal tests --- .../modals/user/DeleteUserModal.test.tsx | 688 ++++++++++++ .../modals/user/DeleteUserModal.tsx | 2 +- .../modals/user/EditUserModal.test.tsx | 978 ++++++++++++++++++ .../components/modals/user/EditUserModal.tsx | 2 +- 4 files changed, 1668 insertions(+), 2 deletions(-) create mode 100644 app/src/components/modals/user/DeleteUserModal.test.tsx create mode 100644 app/src/components/modals/user/EditUserModal.test.tsx diff --git a/app/src/components/modals/user/DeleteUserModal.test.tsx b/app/src/components/modals/user/DeleteUserModal.test.tsx new file mode 100644 index 0000000..4e9f14c --- /dev/null +++ b/app/src/components/modals/user/DeleteUserModal.test.tsx @@ -0,0 +1,688 @@ +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 DeleteUserModal from './DeleteUserModal'; +import { UserRole, type User } from '@/types/models'; + +// 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('DeleteUserModal', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + + const mockUser: User = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + mockOnClose.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when opened with user data', () => { + render( + + ); + + expect(screen.getByText('Delete User')).toBeInTheDocument(); + expect( + screen.getByText( + 'Are you sure you want to delete user "test@example.com"? This action cannot be undone and all associated data will be permanently deleted.' + ) + ).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-delete-user-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-delete-user-button') + ).toBeInTheDocument(); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Delete User')).not.toBeInTheDocument(); + }); + + it('renders modal with null user', () => { + render( + + ); + + expect(screen.getByText('Delete User')).toBeInTheDocument(); + expect( + screen.getByText( + 'Are you sure you want to delete user ""? This action cannot be undone and all associated data will be permanently deleted.' + ) + ).toBeInTheDocument(); + }); + + it('calls onClose when modal is closed via cancel button', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-user-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('User Information Display', () => { + it('displays correct user email in confirmation message', () => { + render( + + ); + + expect( + screen.getByText( + 'Are you sure you want to delete user "test@example.com"? This action cannot be undone and all associated data will be permanently deleted.' + ) + ).toBeInTheDocument(); + }); + + it('handles various email formats in confirmation message', () => { + const emailFormats = [ + 'simple@example.com', + 'user.name@example.com', + 'user+tag@example.com', + 'very.long.email.address@domain.co.uk', + ]; + + emailFormats.forEach((email) => { + const userWithEmail = { ...mockUser, email }; + const { unmount } = render( + + ); + + expect( + screen.getByText( + `Are you sure you want to delete user "${email}"? This action cannot be undone and all associated data will be permanently deleted.` + ) + ).toBeInTheDocument(); + + unmount(); + }); + }); + + it('handles user with special characters in email', () => { + const specialUser = { ...mockUser, email: 'user"with@quotes.com' }; + + render( + + ); + + expect( + screen.getByText( + 'Are you sure you want to delete user "user"with@quotes.com"? This action cannot be undone and all associated data will be permanently deleted.' + ) + ).toBeInTheDocument(); + }); + }); + + describe('Modal Actions', () => { + it('has cancel and delete buttons with correct text', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-user-button'); + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + + expect(cancelButton).toHaveTextContent('Cancel'); + expect(deleteButton).toHaveTextContent('Delete'); + + expect(cancelButton).toHaveRole('button'); + expect(deleteButton).toHaveRole('button'); + }); + + it('calls onConfirm when delete button is clicked', async () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onClose when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-user-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Loading State', () => { + it('shows loading state on delete button when loading', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + expect(deleteButton).toHaveAttribute('data-loading', 'true'); + }); + + it('disables delete button when loading', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + expect(deleteButton).toBeDisabled(); + }); + + it('handles normal state when not loading', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + expect(deleteButton).not.toBeDisabled(); + expect(deleteButton).not.toHaveAttribute('data-loading', 'true'); + }); + }); + + describe('Error Handling', () => { + it('handles deletion errors gracefully', async () => { + mockOnConfirm.mockRejectedValue(new Error('Deletion failed')); + + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + // Modal should handle the error gracefully (not crash) + expect(screen.getByText('Delete User')).toBeInTheDocument(); + }); + + it('handles network errors', async () => { + mockOnConfirm.mockRejectedValue(new Error('Network error')); + + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + // Should not crash the component + expect(screen.getByText('Delete User')).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('has proper modal structure', () => { + render( + + ); + + // Modal should have proper title + expect(screen.getByText('Delete User')).toBeInTheDocument(); + + // Should have confirmation text + expect( + screen.getByText(/Are you sure you want to delete user/) + ).toBeInTheDocument(); + }); + + it('has proper button roles', () => { + render( + + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); + + 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 cancelButton = screen.getByTestId('cancel-delete-user-button'); + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + + // Buttons should be focusable + expect(cancelButton).not.toHaveAttribute('disabled'); + expect(deleteButton).not.toHaveAttribute('disabled'); + + // Should handle keyboard events + fireEvent.keyDown(deleteButton, { key: 'Enter', code: 'Enter' }); + fireEvent.keyDown(cancelButton, { key: 'Escape', code: 'Escape' }); + }); + + it('has proper confirmation message structure', () => { + render( + + ); + + // Check that the user email is properly quoted in the message + expect( + screen.getByText( + /Are you sure you want to delete user "test@example.com"?/ + ) + ).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onConfirm prop correctly', async () => { + const customMockConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(customMockConfirm).toHaveBeenCalledTimes(1); + }); + }); + + it('accepts and uses onClose prop correctly', () => { + const customMockClose = vi.fn(); + + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-user-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 User')).toBeInTheDocument(); + }); + + it('handles different user objects correctly', () => { + const users = [ + { ...mockUser, role: UserRole.Admin }, + { ...mockUser, role: UserRole.Viewer }, + { ...mockUser, email: 'admin@example.com' }, + { ...mockUser, displayName: 'Admin User' }, + ]; + + users.forEach((user) => { + const { unmount } = render( + + ); + + expect(screen.getByText('Delete User')).toBeInTheDocument(); + expect( + screen.getByText( + `Are you sure you want to delete user "${user.email}"?`, + { exact: false } + ) + ).toBeInTheDocument(); + unmount(); + }); + }); + + it('handles opened prop correctly', () => { + const { rerender } = render( + + ); + + // Should not be visible when opened is false + expect(screen.queryByText('Delete User')).not.toBeInTheDocument(); + + rerender( + + + + ); + + // Should be visible when opened is true + expect(screen.getByText('Delete User')).toBeInTheDocument(); + }); + }); + + describe('User Interaction Flow', () => { + it('completes full deletion confirmation flow successfully', async () => { + render( + + ); + + // 1. Modal opens and shows user information + expect(screen.getByText('Delete User')).toBeInTheDocument(); + expect( + screen.getByText( + 'Are you sure you want to delete user "test@example.com"? This action cannot be undone and all associated data will be permanently deleted.' + ) + ).toBeInTheDocument(); + + // 2. User clicks delete + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + fireEvent.click(deleteButton); + + // 3. Confirmation function is called + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + }); + + it('allows user to cancel deletion', () => { + render( + + ); + + // User clicks cancel instead of delete + const cancelButton = screen.getByTestId('cancel-delete-user-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 deleteButton = screen.getByTestId('confirm-delete-user-button'); + + // 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 User')).toBeInTheDocument(); + expect(mockOnConfirm).toHaveBeenCalled(); + }); + }); + + describe('Security Considerations', () => { + it('clearly shows destructive action warning', () => { + render( + + ); + + expect( + screen.getByText( + /This action cannot be undone and all associated data will be permanently deleted/ + ) + ).toBeInTheDocument(); + }); + + it('requires explicit confirmation', () => { + render( + + ); + + // Should show clear delete button + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + expect(deleteButton).toHaveTextContent('Delete'); + }); + + it('displays user identifier for verification', () => { + render( + + ); + + // User should be able to verify they're deleting the right user + expect( + screen.getByText(/delete user "test@example.com"/) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/user/DeleteUserModal.tsx b/app/src/components/modals/user/DeleteUserModal.tsx index aab500a..b854c95 100644 --- a/app/src/components/modals/user/DeleteUserModal.tsx +++ b/app/src/components/modals/user/DeleteUserModal.tsx @@ -44,7 +44,7 @@ const DeleteUserModal: React.FC = ({ loading={loading} data-testid="confirm-delete-user-button" > - Delete User + Delete diff --git a/app/src/components/modals/user/EditUserModal.test.tsx b/app/src/components/modals/user/EditUserModal.test.tsx new file mode 100644 index 0000000..9e4c4d6 --- /dev/null +++ b/app/src/components/modals/user/EditUserModal.test.tsx @@ -0,0 +1,978 @@ +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 EditUserModal from './EditUserModal'; +import { UserRole, type User } from '@/types/models'; + +// 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('EditUserModal', () => { + const mockOnEditUser = vi.fn(); + const mockOnClose = vi.fn(); + + const mockUser: User = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnEditUser.mockResolvedValue(true); + mockOnClose.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when opened with user data', () => { + render( + + ); + + expect(screen.getByText('Edit User')).toBeInTheDocument(); + expect(screen.getByTestId('edit-user-email-input')).toBeInTheDocument(); + expect( + screen.getByTestId('edit-user-display-name-input') + ).toBeInTheDocument(); + expect( + screen.getByTestId('edit-user-password-input') + ).toBeInTheDocument(); + expect(screen.getByTestId('edit-user-role-select')).toBeInTheDocument(); + expect(screen.getByTestId('cancel-edit-user-button')).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-edit-user-button') + ).toBeInTheDocument(); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Edit User')).not.toBeInTheDocument(); + }); + + it('renders modal with null user', () => { + render( + + ); + + expect(screen.getByText('Edit User')).toBeInTheDocument(); + + // Form should have empty values when user is null + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + + expect((emailInput as HTMLInputElement).value).toBe(''); + expect((displayNameInput as HTMLInputElement).value).toBe(''); + }); + + it('calls onClose when modal is closed via cancel button', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-edit-user-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Form Pre-population', () => { + it('pre-populates form with user data', () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + const roleSelect = screen.getByTestId('edit-user-role-select'); + + expect((emailInput as HTMLInputElement).value).toBe('test@example.com'); + expect((displayNameInput as HTMLInputElement).value).toBe('Test User'); + expect((passwordInput as HTMLInputElement).value).toBe(''); // Password should be empty + expect(roleSelect).toHaveDisplayValue('Editor'); + }); + + it('handles user with empty display name', () => { + const userWithoutDisplayName: User = { + ...mockUser, + displayName: '', + }; + + render( + + ); + + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + expect((displayNameInput as HTMLInputElement).value).toBe(''); + }); + + it('updates form when user prop changes', async () => { + const { rerender } = render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + expect((emailInput as HTMLInputElement).value).toBe('test@example.com'); + + const newUser: User = { + ...mockUser, + id: 2, + email: 'newuser@example.com', + displayName: 'New User', + role: UserRole.Admin, + }; + + rerender( + + ); + + // Wait for the useEffect to update the form + await waitFor(() => { + expect((emailInput as HTMLInputElement).value).toBe( + 'newuser@example.com' + ); + }); + + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const roleSelect = screen.getByTestId('edit-user-role-select'); + + expect((displayNameInput as HTMLInputElement).value).toBe('New User'); + expect(roleSelect).toHaveDisplayValue('Admin'); + }); + }); + + describe('Form Interaction', () => { + it('updates email input when typed', () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + fireEvent.change(emailInput, { + target: { value: 'updated@example.com' }, + }); + + expect((emailInput as HTMLInputElement).value).toBe( + 'updated@example.com' + ); + }); + + it('updates display name input when typed', () => { + render( + + ); + + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + fireEvent.change(displayNameInput, { target: { value: 'Updated User' } }); + + expect((displayNameInput as HTMLInputElement).value).toBe('Updated User'); + }); + + it('updates password input when typed', () => { + render( + + ); + + const passwordInput = screen.getByTestId('edit-user-password-input'); + fireEvent.change(passwordInput, { target: { value: 'newpassword123' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('newpassword123'); + }); + + it('updates role selection when changed', async () => { + render( + + ); + + const roleSelect = screen.getByTestId('edit-user-role-select'); + + // Click to open the select dropdown + fireEvent.click(roleSelect); + + // Wait for and click on Admin option + await waitFor(() => { + const adminOption = screen.getByText('Admin'); + fireEvent.click(adminOption); + }); + + // Verify the selection + expect(roleSelect).toHaveDisplayValue('Admin'); + }); + }); + + describe('Form Submission', () => { + it('handles form submission with email and display name changes only', async () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const saveButton = screen.getByTestId('confirm-edit-user-button'); + + fireEvent.change(emailInput, { + target: { value: 'updated@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Updated User' } }); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, { + email: 'updated@example.com', + displayName: 'Updated User', + password: '', + role: mockUser.role, + }); + }); + }); + + it('handles form submission with password change', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('edit-user-password-input'); + const saveButton = screen.getByTestId('confirm-edit-user-button'); + + fireEvent.change(passwordInput, { target: { value: 'newpassword123' } }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, { + email: mockUser.email, + displayName: mockUser.displayName, + password: 'newpassword123', + role: mockUser.role, + }); + }); + }); + + it('handles form submission with role change', async () => { + render( + + ); + + const roleSelect = screen.getByTestId('edit-user-role-select'); + const saveButton = screen.getByTestId('confirm-edit-user-button'); + + // Change role to Admin + fireEvent.click(roleSelect); + await waitFor(() => { + const adminOption = screen.getByText('Admin'); + fireEvent.click(adminOption); + }); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, { + email: mockUser.email, + displayName: mockUser.displayName, + password: '', + role: UserRole.Admin, + }); + }); + }); + + it('does not submit when user is null', () => { + render( + + ); + + const saveButton = screen.getByTestId('confirm-edit-user-button'); + fireEvent.click(saveButton); + + expect(mockOnEditUser).not.toHaveBeenCalled(); + }); + }); + + describe('Password Handling', () => { + it('shows password help text', () => { + render( + + ); + + expect( + screen.getByText('Leave password empty to keep the current password') + ).toBeInTheDocument(); + }); + + it('starts with empty password field', () => { + render( + + ); + + const passwordInput = screen.getByTestId('edit-user-password-input'); + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + + it('maintains empty password when user changes', async () => { + const { rerender } = render( + + ); + + const passwordInput = screen.getByTestId('edit-user-password-input'); + fireEvent.change(passwordInput, { target: { value: 'somepassword' } }); + + const newUser: User = { ...mockUser, id: 2, email: 'new@example.com' }; + + rerender( + + ); + + // Wait for the useEffect to reset the password field + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + }); + + describe('Role Selection', () => { + it('pre-selects correct role for Admin user', () => { + const adminUser: User = { ...mockUser, role: UserRole.Admin }; + + render( + + ); + + const roleSelect = screen.getByTestId('edit-user-role-select'); + expect(roleSelect).toHaveDisplayValue('Admin'); + }); + + it('pre-selects correct role for Viewer user', () => { + const viewerUser: User = { ...mockUser, role: UserRole.Viewer }; + + render( + + ); + + const roleSelect = screen.getByTestId('edit-user-role-select'); + expect(roleSelect).toHaveDisplayValue('Viewer'); + }); + + it('allows changing from Editor to Viewer', async () => { + render( + + ); + + const roleSelect = screen.getByTestId('edit-user-role-select'); + + // Initial role should be Editor + expect(roleSelect).toHaveDisplayValue('Editor'); + + // Change to Viewer + fireEvent.click(roleSelect); + await waitFor(() => { + const viewerOption = screen.getByText('Viewer'); + fireEvent.click(viewerOption); + }); + + expect(roleSelect).toHaveDisplayValue('Viewer'); + }); + }); + + describe('Loading State', () => { + it('shows loading state on save button when loading', () => { + render( + + ); + + const saveButton = screen.getByTestId('confirm-edit-user-button'); + expect(saveButton).toHaveAttribute('data-loading', 'true'); + }); + + it('disables save button when loading', () => { + render( + + ); + + const saveButton = screen.getByTestId('confirm-edit-user-button'); + expect(saveButton).toBeDisabled(); + }); + + it('handles normal state when not loading', () => { + render( + + ); + + const saveButton = screen.getByTestId('confirm-edit-user-button'); + expect(saveButton).not.toBeDisabled(); + expect(saveButton).not.toHaveAttribute('data-loading', 'true'); + }); + }); + + describe('Error Handling', () => { + it('handles edit errors gracefully', async () => { + mockOnEditUser.mockResolvedValue(false); + + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const saveButton = screen.getByTestId('confirm-edit-user-button'); + + fireEvent.change(emailInput, { target: { value: 'error@example.com' } }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalled(); + }); + expect(mockOnClose).not.toHaveBeenCalled(); + expect(screen.getByText('Edit User')).toBeInTheDocument(); + }); + + it('handles edit promise rejection', async () => { + mockOnEditUser.mockRejectedValue(new Error('Network error')); + + render( + + ); + + const saveButton = screen.getByTestId('confirm-edit-user-button'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalled(); + }); + expect(screen.getByText('Edit User')).toBeInTheDocument(); + }); + + it('retains form values when edit fails', async () => { + mockOnEditUser.mockResolvedValue(false); + + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + + fireEvent.change(emailInput, { + target: { value: 'persist@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Persist User' } }); + + const saveButton = screen.getByTestId('confirm-edit-user-button'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalled(); + }); + // Form should retain values since submission failed + expect((emailInput as HTMLInputElement).value).toBe( + 'persist@example.com' + ); + expect((displayNameInput as HTMLInputElement).value).toBe('Persist User'); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and structure', () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + const roleSelect = screen.getByTestId('edit-user-role-select'); + + expect(emailInput).toHaveAccessibleName(); + expect(displayNameInput).toHaveAccessibleName(); + expect(passwordInput).toHaveAccessibleName(); + expect(roleSelect).toHaveAccessibleName(); + + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('has proper button roles', () => { + render( + + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const saveButton = screen.getByRole('button', { name: /save changes/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(saveButton).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + + // All inputs should be focusable + expect(emailInput).not.toHaveAttribute('disabled'); + expect(displayNameInput).not.toHaveAttribute('disabled'); + expect(passwordInput).not.toHaveAttribute('disabled'); + + // Test keyboard input + fireEvent.change(emailInput, { target: { value: 'keyboard@test.com' } }); + expect((emailInput as HTMLInputElement).value).toBe('keyboard@test.com'); + }); + + it('has proper modal structure', () => { + render( + + ); + + expect(screen.getByText('Edit User')).toBeInTheDocument(); + expect(screen.getByTestId('edit-user-email-input')).toBeInTheDocument(); + expect( + screen.getByTestId('edit-user-display-name-input') + ).toBeInTheDocument(); + expect( + screen.getByTestId('edit-user-password-input') + ).toBeInTheDocument(); + expect(screen.getByTestId('edit-user-role-select')).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses props correctly for display', () => { + const customMockEdit = vi.fn().mockResolvedValue(true); + const customMockClose = vi.fn(); + + render( + + ); + + // Test that props are accepted and modal renders + expect(screen.getByText('Edit User')).toBeInTheDocument(); + + const cancelButton = screen.getByTestId('cancel-edit-user-button'); + fireEvent.click(cancelButton); + + expect(customMockClose).toHaveBeenCalled(); + }); + + it('handles function props correctly', () => { + const testOnEdit = vi.fn(); + const testOnClose = vi.fn(); + + expect(() => { + render( + + ); + }).not.toThrow(); + + expect(screen.getByText('Edit User')).toBeInTheDocument(); + }); + + it('handles different user objects correctly', () => { + const users = [ + { ...mockUser, role: UserRole.Admin }, + { ...mockUser, role: UserRole.Viewer }, + { ...mockUser, displayName: '' }, + { ...mockUser, displayName: 'Very Long Display Name Here' }, + ]; + + users.forEach((user) => { + const { unmount } = render( + + ); + + expect(screen.getByText('Edit User')).toBeInTheDocument(); + unmount(); + }); + }); + }); + + describe('User Interaction Flow', () => { + it('allows editing user information but submission fails due to component bug', async () => { + render( + + ); + + // 1. Modal opens and shows pre-populated form + expect(screen.getByText('Edit User')).toBeInTheDocument(); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + const roleSelect = screen.getByTestId('edit-user-role-select'); + + // Verify pre-population + expect((emailInput as HTMLInputElement).value).toBe('test@example.com'); + expect((displayNameInput as HTMLInputElement).value).toBe('Test User'); + expect(roleSelect).toHaveDisplayValue('Editor'); + + // 2. User modifies form + fireEvent.change(emailInput, { + target: { value: 'modified@example.com' }, + }); + fireEvent.change(displayNameInput, { + target: { value: 'Modified User' }, + }); + fireEvent.change(passwordInput, { target: { value: 'newpassword' } }); + + // 3. Change role to Admin + fireEvent.click(roleSelect); + await waitFor(() => { + const adminOption = screen.getByText('Admin'); + fireEvent.click(adminOption); + }); + + expect(roleSelect).toHaveDisplayValue('Admin'); + + // 4. Try to submit form + const saveButton = screen.getByTestId('confirm-edit-user-button'); + fireEvent.click(saveButton); + + // 5. Should call edit with correct arguments + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, { + email: 'modified@example.com', + displayName: 'Modified User', + password: 'newpassword', + role: UserRole.Admin, + }); + }); + }); + + it('allows user to cancel edit', () => { + render( + + ); + + // User modifies form but then cancels + const emailInput = screen.getByTestId('edit-user-email-input'); + fireEvent.change(emailInput, { target: { value: 'cancel@example.com' } }); + + const cancelButton = screen.getByTestId('cancel-edit-user-button'); + fireEvent.click(cancelButton); + + // Should close modal without calling edit function + expect(mockOnEditUser).not.toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('handles form clearing when user changes', async () => { + const { rerender } = render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + + // Verify initial user data + expect((emailInput as HTMLInputElement).value).toBe('test@example.com'); + + // Change to different user + const newUser: User = { + ...mockUser, + id: 2, + email: 'different@example.com', + displayName: 'Different User', + role: UserRole.Admin, + }; + + rerender( + + ); + + // Wait for form to update with new user data + await waitFor(() => { + expect((emailInput as HTMLInputElement).value).toBe( + 'different@example.com' + ); + }); + }); + }); +}); diff --git a/app/src/components/modals/user/EditUserModal.tsx b/app/src/components/modals/user/EditUserModal.tsx index 74f511b..8c7acef 100644 --- a/app/src/components/modals/user/EditUserModal.tsx +++ b/app/src/components/modals/user/EditUserModal.tsx @@ -122,7 +122,7 @@ const EditUserModal: React.FC = ({ Cancel