diff --git a/app/src/components/modals/user/CreateUserModal.test.tsx b/app/src/components/modals/user/CreateUserModal.test.tsx new file mode 100644 index 0000000..75bca8b --- /dev/null +++ b/app/src/components/modals/user/CreateUserModal.test.tsx @@ -0,0 +1,875 @@ +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 CreateUserModal from './CreateUserModal'; +import { UserRole } from '@/types/models'; +import type { CreateUserRequest } from '@/types/api'; + +// 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('CreateUserModal', () => { + const mockOnCreateUser = vi.fn(); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnCreateUser.mockResolvedValue(true); + mockOnClose.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when opened', () => { + render( + + ); + + expect(screen.getByText('Create New User')).toBeInTheDocument(); + expect(screen.getByTestId('create-user-email-input')).toBeInTheDocument(); + expect( + screen.getByTestId('create-user-display-name-input') + ).toBeInTheDocument(); + expect( + screen.getByTestId('create-user-password-input') + ).toBeInTheDocument(); + expect(screen.getByTestId('create-user-role-select')).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-create-user-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-create-user-button') + ).toBeInTheDocument(); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Create New User')).not.toBeInTheDocument(); + }); + + it('calls onClose when modal is closed via cancel button', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-create-user-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Form Interaction', () => { + it('updates email input when typed', () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + + expect((emailInput as HTMLInputElement).value).toBe('test@example.com'); + }); + + it('updates display name input when typed', () => { + render( + + ); + + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + fireEvent.change(displayNameInput, { target: { value: 'John Doe' } }); + + expect((displayNameInput as HTMLInputElement).value).toBe('John Doe'); + }); + + it('updates password input when typed', () => { + render( + + ); + + const passwordInput = screen.getByTestId('create-user-password-input'); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('password123'); + }); + + it('updates role selection when changed', async () => { + render( + + ); + + const roleSelect = screen.getByTestId('create-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 (check for the label, not the enum value) + expect(roleSelect).toHaveDisplayValue('Admin'); + }); + + it('handles form submission with valid data', async () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(displayNameInput, { target: { value: 'Test User' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + + fireEvent.click(createButton); + + const expectedUserData: CreateUserRequest = { + email: 'test@example.com', + displayName: 'Test User', + password: 'password123', + role: UserRole.Viewer, // Default role + }; + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith(expectedUserData); + }); + }); + + it('closes modal and clears form after successful creation', async () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { + target: { value: 'success@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Success User' } }); + fireEvent.change(passwordInput, { target: { value: 'successpass' } }); + + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + + // Form should be cleared + expect((emailInput as HTMLInputElement).value).toBe(''); + expect((displayNameInput as HTMLInputElement).value).toBe(''); + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + describe('Role Selection', () => { + it('defaults to Viewer role', () => { + render( + + ); + + const roleSelect = screen.getByTestId('create-user-role-select'); + expect(roleSelect).toHaveDisplayValue('Viewer'); + }); + + it('allows selecting Admin role', async () => { + render( + + ); + + const roleSelect = screen.getByTestId('create-user-role-select'); + const emailInput = screen.getByTestId('create-user-email-input'); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + // Set role to Admin + fireEvent.click(roleSelect); + await waitFor(() => { + const adminOption = screen.getByText('Admin'); + fireEvent.click(adminOption); + }); + + // Fill required fields + fireEvent.change(emailInput, { target: { value: 'admin@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'adminpass' } }); + + fireEvent.click(createButton); + + const expectedUserData: CreateUserRequest = { + email: 'admin@example.com', + displayName: '', + password: 'adminpass', + role: UserRole.Admin, + }; + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith(expectedUserData); + }); + }); + + it('allows selecting Editor role', async () => { + render( + + ); + + const roleSelect = screen.getByTestId('create-user-role-select'); + const emailInput = screen.getByTestId('create-user-email-input'); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + // Set role to Editor + fireEvent.click(roleSelect); + await waitFor(() => { + const editorOption = screen.getByText('Editor'); + fireEvent.click(editorOption); + }); + + // Fill required fields + fireEvent.change(emailInput, { target: { value: 'editor@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'editorpass' } }); + + fireEvent.click(createButton); + + const expectedUserData: CreateUserRequest = { + email: 'editor@example.com', + displayName: '', + password: 'editorpass', + role: UserRole.Editor, + }; + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith(expectedUserData); + }); + }); + }); + + describe('Form Validation', () => { + it('handles empty email field', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + // Only fill password, leave email empty + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(createButton); + + // Should still call onCreateUser (validation might be handled elsewhere) + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email: '', + displayName: '', + password: 'password123', + role: UserRole.Viewer, + }); + }); + }); + + it('handles empty password field', async () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + // Only fill email, leave password empty + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email: 'test@example.com', + displayName: '', + password: '', + role: UserRole.Viewer, + }); + }); + }); + + it('handles various email formats', async () => { + const emailFormats = [ + 'simple@example.com', + 'user.name@example.com', + 'user+tag@example.com', + 'very.long.email.address@domain.co.uk', + ]; + + for (const email of emailFormats) { + const { unmount } = render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { target: { value: email } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email, + displayName: '', + password: 'password123', + role: UserRole.Viewer, + }); + }); + + unmount(); + vi.clearAllMocks(); + mockOnCreateUser.mockResolvedValue(true); + } + }); + + it('handles various display names', async () => { + const displayNames = [ + 'John Doe', + 'María García', + 'Jean-Pierre', + "O'Connor", + 'Smith Jr.', + '田中太郎', + ]; + + for (const displayName of displayNames) { + const { unmount } = render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(displayNameInput, { target: { value: displayName } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email: 'test@example.com', + displayName, + password: 'password123', + role: UserRole.Viewer, + }); + }); + + unmount(); + vi.clearAllMocks(); + mockOnCreateUser.mockResolvedValue(true); + } + }); + }); + + describe('Loading State', () => { + it('shows loading state on create button when loading', () => { + render( + + ); + + const createButton = screen.getByTestId('confirm-create-user-button'); + expect(createButton).toHaveAttribute('data-loading', 'true'); + }); + + it('disables form elements when loading', () => { + render( + + ); + + // Button should be disabled during loading + const createButton = screen.getByTestId('confirm-create-user-button'); + expect(createButton).toBeDisabled(); + }); + + it('handles normal state when not loading', () => { + render( + + ); + + const createButton = screen.getByTestId('confirm-create-user-button'); + expect(createButton).not.toBeDisabled(); + expect(createButton).not.toHaveAttribute('data-loading', 'true'); + }); + }); + + describe('Error Handling', () => { + it('handles creation errors gracefully', async () => { + mockOnCreateUser.mockResolvedValue(false); + + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { target: { value: 'error@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'errorpass' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalled(); + }); + + // Modal should remain open when creation fails + expect(mockOnClose).not.toHaveBeenCalled(); + expect(screen.getByText('Create New User')).toBeInTheDocument(); + }); + + it('handles creation promise rejection', async () => { + mockOnCreateUser.mockRejectedValue(new Error('Network error')); + + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { target: { value: 'reject@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'rejectpass' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalled(); + }); + + // Modal should handle the error gracefully (not crash) + expect(screen.getByText('Create New User')).toBeInTheDocument(); + }); + + it('does not clear form when creation fails', async () => { + mockOnCreateUser.mockResolvedValue(false); + + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { + target: { value: 'persist@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Persist User' } }); + fireEvent.change(passwordInput, { target: { value: 'persistpass' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalled(); + }); + + // Form should retain values when creation fails + expect((emailInput as HTMLInputElement).value).toBe( + 'persist@example.com' + ); + expect((displayNameInput as HTMLInputElement).value).toBe('Persist User'); + expect((passwordInput as HTMLInputElement).value).toBe('persistpass'); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and structure', () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + const roleSelect = screen.getByTestId('create-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 createButton = screen.getByRole('button', { name: /create user/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(createButton).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-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('Create New User')).toBeInTheDocument(); + expect(screen.getByTestId('create-user-email-input')).toBeInTheDocument(); + expect( + screen.getByTestId('create-user-display-name-input') + ).toBeInTheDocument(); + expect( + screen.getByTestId('create-user-password-input') + ).toBeInTheDocument(); + expect(screen.getByTestId('create-user-role-select')).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onCreateUser prop correctly', async () => { + const customMockCreate = vi.fn().mockResolvedValue(true); + + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const passwordInput = screen.getByTestId('create-user-password-input'); + const createButton = screen.getByTestId('confirm-create-user-button'); + + fireEvent.change(emailInput, { target: { value: 'custom@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'custompass' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(customMockCreate).toHaveBeenCalledWith({ + email: 'custom@example.com', + displayName: '', + password: 'custompass', + role: UserRole.Viewer, + }); + }); + }); + + it('accepts and uses onClose prop correctly', () => { + const customMockClose = vi.fn(); + + render( + + ); + + const cancelButton = screen.getByTestId('cancel-create-user-button'); + fireEvent.click(cancelButton); + + expect(customMockClose).toHaveBeenCalled(); + }); + + it('handles function props correctly', () => { + const testOnCreate = vi.fn(); + const testOnClose = vi.fn(); + + expect(() => { + render( + + ); + }).not.toThrow(); + + expect(screen.getByText('Create New User')).toBeInTheDocument(); + }); + }); + + describe('User Interaction Flow', () => { + it('completes full user creation flow successfully', async () => { + render( + + ); + + // 1. Modal opens and shows form + expect(screen.getByText('Create New User')).toBeInTheDocument(); + + // 2. User fills out form + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + const roleSelect = screen.getByTestId('create-user-role-select'); + + fireEvent.change(emailInput, { + target: { value: 'complete@example.com' }, + }); + fireEvent.change(displayNameInput, { + target: { value: 'Complete User' }, + }); + fireEvent.change(passwordInput, { target: { value: 'completepass' } }); + + // 3. Change role to Editor + fireEvent.click(roleSelect); + await waitFor(() => { + const editorOption = screen.getByText('Editor'); + fireEvent.click(editorOption); + }); + + // 4. Submit form + const createButton = screen.getByTestId('confirm-create-user-button'); + fireEvent.click(createButton); + + // 5. Verify creation call + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email: 'complete@example.com', + displayName: 'Complete User', + password: 'completepass', + role: UserRole.Editor, + }); + }); + + // 6. Modal closes and form clears + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('allows user to cancel user creation', () => { + render( + + ); + + // User fills form but then cancels + const emailInput = screen.getByTestId('create-user-email-input'); + fireEvent.change(emailInput, { target: { value: 'cancel@example.com' } }); + + const cancelButton = screen.getByTestId('cancel-create-user-button'); + fireEvent.click(cancelButton); + + // Should close modal without calling create function + expect(mockOnCreateUser).not.toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/components/modals/user/CreateUserModal.tsx b/app/src/components/modals/user/CreateUserModal.tsx index b346390..3e2fb43 100644 --- a/app/src/components/modals/user/CreateUserModal.tsx +++ b/app/src/components/modals/user/CreateUserModal.tsx @@ -94,7 +94,7 @@ const CreateUserModal: React.FC = ({ Cancel