diff --git a/app/src/components/settings/account/AccountSettings.test.tsx b/app/src/components/settings/account/AccountSettings.test.tsx new file mode 100644 index 0000000..cdf60ff --- /dev/null +++ b/app/src/components/settings/account/AccountSettings.test.tsx @@ -0,0 +1,246 @@ +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 AccountSettings from './AccountSettings'; + +// Mock the auth context +const mockUser = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: 'editor' as const, +}; +const mockRefreshUser = vi.fn(); +vi.mock('../../../contexts/AuthContext', () => ({ + useAuth: () => ({ + user: mockUser, + refreshUser: mockRefreshUser, + }), +})); + +// Mock the profile settings hook +const mockUpdateProfile = vi.fn(); +vi.mock('../../../hooks/useProfileSettings', () => ({ + useProfileSettings: () => ({ + loading: false, + updateProfile: mockUpdateProfile, + }), +})); + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock the sub-components +vi.mock('./ProfileSettings', () => ({ + default: ({ + settings, + onInputChange, + }: { + settings: { + displayName?: string; + email?: string; + }; + onInputChange: (field: string, value: string) => void; + }) => ( +
+ onInputChange('displayName', e.target.value)} + /> + onInputChange('email', e.target.value)} + /> +
+ ), +})); + +vi.mock('./SecuritySettings', () => ({ + default: ({ + settings, + onInputChange, + }: { + settings: { + currentPassword?: string; + newPassword?: string; + }; + onInputChange: (field: string, value: string) => void; + }) => ( +
+ onInputChange('currentPassword', e.target.value)} + /> + onInputChange('newPassword', e.target.value)} + /> +
+ ), +})); + +vi.mock('./DangerZoneSettings', () => ({ + default: () =>
Danger Zone
, +})); + +vi.mock('../../modals/account/EmailPasswordModal', () => ({ + default: ({ + opened, + onConfirm, + }: { + opened: boolean; + onConfirm: (password: string) => void; + }) => + opened ? ( +
+ +
+ ) : null, +})); + +// 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('AccountSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdateProfile.mockResolvedValue(mockUser); + mockRefreshUser.mockResolvedValue(undefined); + }); + + it('renders modal with all sections', () => { + render(); + + expect(screen.getByText('Account Settings')).toBeInTheDocument(); + expect(screen.getByTestId('profile-settings')).toBeInTheDocument(); + expect(screen.getByTestId('security-settings')).toBeInTheDocument(); + expect(screen.getByTestId('danger-zone-settings')).toBeInTheDocument(); + }); + + it('shows unsaved changes badge when settings are modified', () => { + render(); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } }); + + expect(screen.getByText('Unsaved Changes')).toBeInTheDocument(); + }); + + it('enables save button when there are changes', () => { + render(); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + expect(saveButton).toBeDisabled(); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } }); + + expect(saveButton).not.toBeDisabled(); + }); + + it('saves profile changes successfully', async () => { + const mockOnClose = vi.fn(); + render(); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } }); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockUpdateProfile).toHaveBeenCalledWith( + expect.objectContaining({ displayName: 'Updated Name' }) + ); + }); + + await waitFor(() => { + expect(mockRefreshUser).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('opens email confirmation modal for email changes', () => { + render(); + + const emailInput = screen.getByTestId('email-input'); + fireEvent.change(emailInput, { target: { value: 'new@example.com' } }); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + fireEvent.click(saveButton); + + expect(screen.getByTestId('email-password-modal')).toBeInTheDocument(); + }); + + it('completes email change with password confirmation', async () => { + const mockOnClose = vi.fn(); + render(); + + const emailInput = screen.getByTestId('email-input'); + fireEvent.change(emailInput, { target: { value: 'new@example.com' } }); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + fireEvent.click(saveButton); + + const confirmButton = screen.getByTestId('confirm-email'); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockUpdateProfile).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'new@example.com', + currentPassword: 'test-password', + }) + ); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('closes modal when cancel is clicked', () => { + const mockOnClose = vi.fn(); + render(); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('does not render when closed', () => { + render(); + + expect(screen.queryByText('Account Settings')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/account/DangerZoneSettings.test.tsx b/app/src/components/settings/account/DangerZoneSettings.test.tsx new file mode 100644 index 0000000..349ffd4 --- /dev/null +++ b/app/src/components/settings/account/DangerZoneSettings.test.tsx @@ -0,0 +1,140 @@ +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 DangerZoneSettings from './DangerZoneSettings'; + +// Mock the auth context +const mockLogout = vi.fn(); +vi.mock('../../../contexts/AuthContext', () => ({ + useAuth: () => ({ logout: mockLogout }), +})); + +// Mock the profile settings hook +const mockDeleteAccount = vi.fn(); +vi.mock('../../../hooks/useProfileSettings', () => ({ + useProfileSettings: () => ({ deleteAccount: mockDeleteAccount }), +})); + +// Mock the DeleteAccountModal +vi.mock('../../modals/account/DeleteAccountModal', () => ({ + default: ({ + opened, + onClose, + onConfirm, + }: { + opened: boolean; + onClose: () => void; + onConfirm: (password: string) => void; + }) => + opened ? ( +
+ + +
+ ) : null, +})); + +// 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('DangerZoneSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDeleteAccount.mockResolvedValue(true); + mockLogout.mockResolvedValue(undefined); + }); + + it('renders delete button with warning text', () => { + render(); + + expect( + screen.getByRole('button', { name: 'Delete Account' }) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Once you delete your account, there is no going back. Please be certain.' + ) + ).toBeInTheDocument(); + }); + + it('opens and closes delete modal', () => { + render(); + + const deleteButton = screen.getByRole('button', { name: 'Delete Account' }); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('modal-close')); + expect( + screen.queryByTestId('delete-account-modal') + ).not.toBeInTheDocument(); + }); + + it('completes account deletion and logout flow', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.click(screen.getByTestId('modal-confirm')); + + await waitFor(() => { + expect(mockDeleteAccount).toHaveBeenCalledWith('test-password'); + }); + + await waitFor(() => { + expect(mockLogout).toHaveBeenCalled(); + }); + + expect( + screen.queryByTestId('delete-account-modal') + ).not.toBeInTheDocument(); + }); + + it('keeps modal open when deletion fails', async () => { + mockDeleteAccount.mockResolvedValue(false); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.click(screen.getByTestId('modal-confirm')); + + await waitFor(() => { + expect(mockDeleteAccount).toHaveBeenCalled(); + }); + + expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument(); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it('allows cancellation of deletion process', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.click(screen.getByTestId('modal-close')); + + expect( + screen.queryByTestId('delete-account-modal') + ).not.toBeInTheDocument(); + expect(mockDeleteAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/settings/account/ProfileSettings.test.tsx b/app/src/components/settings/account/ProfileSettings.test.tsx new file mode 100644 index 0000000..124022d --- /dev/null +++ b/app/src/components/settings/account/ProfileSettings.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import ProfileSettings from './ProfileSettings'; +import type { UserProfileSettings } from '@/types/models'; + +// 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('ProfileSettings', () => { + const mockOnInputChange = vi.fn(); + + const defaultSettings: UserProfileSettings = { + displayName: 'John Doe', + email: 'john.doe@example.com', + currentPassword: '', + newPassword: '', + }; + + const emptySettings: UserProfileSettings = { + displayName: '', + email: '', + currentPassword: '', + newPassword: '', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders form fields with current values', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + const emailInput = screen.getByTestId('email-input'); + + expect(displayNameInput).toHaveValue('John Doe'); + expect(emailInput).toHaveValue('john.doe@example.com'); + }); + + it('renders with empty settings', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + const emailInput = screen.getByTestId('email-input'); + + expect(displayNameInput).toHaveValue(''); + expect(emailInput).toHaveValue(''); + }); + + it('calls onInputChange when display name is modified', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Jane Smith' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith('displayName', 'Jane Smith'); + }); + + it('calls onInputChange when email is modified', () => { + render( + + ); + + const emailInput = screen.getByTestId('email-input'); + fireEvent.change(emailInput, { target: { value: 'jane@example.com' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith('email', 'jane@example.com'); + }); + + it('has correct input types and accessibility', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + const emailInput = screen.getByTestId('email-input'); + + expect(displayNameInput).toHaveAttribute('type', 'text'); + expect(emailInput).toHaveAttribute('type', 'email'); + expect(displayNameInput).toHaveAccessibleName(); + expect(emailInput).toHaveAccessibleName(); + }); +}); diff --git a/app/src/components/settings/account/SecuritySettings.test.tsx b/app/src/components/settings/account/SecuritySettings.test.tsx new file mode 100644 index 0000000..8a999aa --- /dev/null +++ b/app/src/components/settings/account/SecuritySettings.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import SecuritySettings from './SecuritySettings'; +import type { UserProfileSettings } from '@/types/models'; + +// 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('SecuritySettings', () => { + const mockOnInputChange = vi.fn(); + + const defaultSettings: UserProfileSettings = { + displayName: 'John Doe', + email: 'john@example.com', + currentPassword: '', + newPassword: '', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all password fields', () => { + render( + + ); + + expect(screen.getByLabelText('Current Password')).toBeInTheDocument(); + expect(screen.getByLabelText('New Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Confirm New Password')).toBeInTheDocument(); + }); + + it('calls onInputChange for current password', () => { + render( + + ); + + const currentPasswordInput = screen.getByLabelText('Current Password'); + fireEvent.change(currentPasswordInput, { target: { value: 'oldpass123' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith( + 'currentPassword', + 'oldpass123' + ); + }); + + it('calls onInputChange for new password', () => { + render( + + ); + + const newPasswordInput = screen.getByLabelText('New Password'); + fireEvent.change(newPasswordInput, { target: { value: 'newpass123' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith('newPassword', 'newpass123'); + }); + + it('shows error when passwords do not match', () => { + render( + + ); + + const confirmPasswordInput = screen.getByLabelText('Confirm New Password'); + fireEvent.change(confirmPasswordInput, { + target: { value: 'different123' }, + }); + + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + }); + + it('clears error when passwords match', () => { + render( + + ); + + const confirmPasswordInput = screen.getByLabelText('Confirm New Password'); + + // First make them not match + fireEvent.change(confirmPasswordInput, { + target: { value: 'different123' }, + }); + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + + // Then make them match + fireEvent.change(confirmPasswordInput, { + target: { value: 'password123' }, + }); + expect( + screen.queryByText('Passwords do not match') + ).not.toBeInTheDocument(); + }); + + it('has correct input types and help text', () => { + render( + + ); + + const currentPasswordInput = screen.getByLabelText('Current Password'); + const newPasswordInput = screen.getByLabelText('New Password'); + const confirmPasswordInput = screen.getByLabelText('Confirm New Password'); + + expect(currentPasswordInput).toHaveAttribute('type', 'password'); + expect(newPasswordInput).toHaveAttribute('type', 'password'); + expect(confirmPasswordInput).toHaveAttribute('type', 'password'); + + expect( + screen.getByText(/Password must be at least 8 characters long/) + ).toBeInTheDocument(); + }); +});