diff --git a/app/src/components/navigation/UserMenu.test.tsx b/app/src/components/navigation/UserMenu.test.tsx new file mode 100644 index 0000000..a1f744b --- /dev/null +++ b/app/src/components/navigation/UserMenu.test.tsx @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../test/utils'; +import UserMenu from './UserMenu'; +import { UserRole } from '../../types/models'; + +// Mock the contexts +vi.mock('../../contexts/AuthContext', () => ({ + useAuth: vi.fn(), +})); + +// Mock the settings components +vi.mock('../settings/account/AccountSettings', () => ({ + default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => ( +
+ +
+ ), +})); + +vi.mock('../settings/admin/AdminDashboard', () => ({ + default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => ( +
+ +
+ ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('UserMenu', () => { + const mockLogout = vi.fn(); + const mockUser = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useAuth } = await import('../../contexts/AuthContext'); + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + logout: mockLogout, + loading: false, + initialized: true, + login: vi.fn(), + refreshToken: vi.fn(), + refreshUser: vi.fn(), + }); + }); + + it('renders user avatar and shows user info when clicked', async () => { + const { getByLabelText, getByText } = render( + + + + ); + + // Find and click the avatar + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + // Check if user info is displayed in popover + await waitFor(() => { + expect(getByText('Test User')).toBeInTheDocument(); + }); + }); + + it('shows admin dashboard option for admin users only', async () => { + // Test admin user sees admin option + const { useAuth } = await import('../../contexts/AuthContext'); + vi.mocked(useAuth).mockReturnValue({ + user: { ...mockUser, role: UserRole.Admin }, + logout: mockLogout, + loading: false, + initialized: true, + login: vi.fn(), + refreshToken: vi.fn(), + refreshUser: vi.fn(), + }); + + const { getByLabelText, getByText } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + expect(getByText('Admin Dashboard')).toBeInTheDocument(); + }); + }); + + it('opens account settings modal when clicked', async () => { + const { getByLabelText, getByText, getByTestId } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + const accountSettingsButton = getByText('Account Settings'); + fireEvent.click(accountSettingsButton); + }); + + await waitFor(() => { + const modal = getByTestId('account-settings-modal'); + expect(modal).toHaveAttribute('data-opened', 'true'); + }); + }); + + it('calls logout when logout button is clicked', async () => { + const { getByLabelText, getByText } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + const logoutButton = getByText('Logout'); + fireEvent.click(logoutButton); + }); + + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it('displays user email when displayName is not available', async () => { + const { useAuth } = await import('../../contexts/AuthContext'); + const userWithoutDisplayName = { + id: mockUser.id, + email: mockUser.email, + role: mockUser.role, + createdAt: mockUser.createdAt, + lastWorkspaceId: mockUser.lastWorkspaceId, + }; + + vi.mocked(useAuth).mockReturnValue({ + user: userWithoutDisplayName, + logout: mockLogout, + loading: false, + initialized: true, + login: vi.fn(), + refreshToken: vi.fn(), + refreshUser: vi.fn(), + }); + + const { getByLabelText, getByText } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + expect(getByText('test@example.com')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/navigation/UserMenu.tsx b/app/src/components/navigation/UserMenu.tsx index c7177b8..6d30bca 100644 --- a/app/src/components/navigation/UserMenu.tsx +++ b/app/src/components/navigation/UserMenu.tsx @@ -47,6 +47,10 @@ const UserMenu: React.FC = () => { radius="xl" style={{ cursor: 'pointer' }} onClick={() => setOpened((o) => !o)} + aria-label="User menu" + aria-expanded={opened} + aria-haspopup="menu" + role="button" > diff --git a/app/src/components/navigation/WorkspaceSwitcher.test.tsx b/app/src/components/navigation/WorkspaceSwitcher.test.tsx new file mode 100644 index 0000000..12516d2 --- /dev/null +++ b/app/src/components/navigation/WorkspaceSwitcher.test.tsx @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../test/utils'; +import WorkspaceSwitcher from './WorkspaceSwitcher'; +import { Theme } from '../../types/models'; + +// Mock the hooks and contexts +vi.mock('../../hooks/useWorkspace', () => ({ + useWorkspace: vi.fn(), +})); + +vi.mock('../../contexts/ModalContext', () => ({ + useModalContext: vi.fn(), +})); + +// Mock API +vi.mock('../../api/workspace', () => ({ + listWorkspaces: vi.fn(), +})); + +// Mock the CreateWorkspaceModal component +vi.mock('../modals/workspace/CreateWorkspaceModal', () => ({ + default: ({ + onWorkspaceCreated, + }: { + onWorkspaceCreated: (workspace: { + name: string; + createdAt: number; + }) => void; + }) => ( +
+ +
+ ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('WorkspaceSwitcher', () => { + const mockSwitchWorkspace = vi.fn(); + const mockSetSettingsModalVisible = vi.fn(); + const mockSetCreateWorkspaceModalVisible = vi.fn(); + + const mockCurrentWorkspace = { + id: 1, + name: 'Current Workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }; + + const mockWorkspaces = [ + mockCurrentWorkspace, + { + id: 2, + name: 'Other Workspace', + createdAt: '2024-01-02T00:00:00Z', + theme: Theme.Dark, + autoSave: true, + showHiddenFiles: true, + gitEnabled: true, + gitUrl: 'https://github.com/test/repo', + gitUser: 'testuser', + gitToken: 'token', + gitAutoCommit: true, + gitCommitMsgTemplate: 'Auto: ${action} ${filename}', + gitCommitName: 'Test User', + gitCommitEmail: 'test@example.com', + }, + ]; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: mockCurrentWorkspace, + workspaces: [], + settings: mockCurrentWorkspace, + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: mockSwitchWorkspace, + deleteCurrentWorkspace: vi.fn(), + }); + + const { useModalContext } = await import('../../contexts/ModalContext'); + vi.mocked(useModalContext).mockReturnValue({ + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: mockSetSettingsModalVisible, + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible, + }); + + const { listWorkspaces } = await import('../../api/workspace'); + vi.mocked(listWorkspaces).mockResolvedValue(mockWorkspaces); + }); + + it('renders current workspace name', () => { + const { getByText } = render( + + + + ); + + expect(getByText('Current Workspace')).toBeInTheDocument(); + }); + + it('shows "No workspace" when no current workspace', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: null, + workspaces: [], + settings: mockCurrentWorkspace, + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: mockSwitchWorkspace, + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByText } = render( + + + + ); + + expect(getByText('No workspace')).toBeInTheDocument(); + }); + + it('opens popover and shows workspace list when clicked', async () => { + const { getByText } = render( + + + + ); + + // Click to open popover + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + // Should see the workspaces header and workspace list + await waitFor(() => { + expect(getByText('Workspaces')).toBeInTheDocument(); + expect(getByText('Other Workspace')).toBeInTheDocument(); + }); + }); + + it('switches workspace when another workspace is clicked', async () => { + const { getByText } = render( + + + + ); + + // Open popover and click on other workspace + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + await waitFor(() => { + const otherWorkspace = getByText('Other Workspace'); + fireEvent.click(otherWorkspace); + }); + + expect(mockSwitchWorkspace).toHaveBeenCalledWith('Other Workspace'); + }); + + it('opens create workspace modal when create button is clicked', async () => { + const { getByText, getByLabelText } = render( + + + + ); + + // Open popover and click create button + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + await waitFor(() => { + const createButton = getByLabelText('Create New Workspace'); + fireEvent.click(createButton); + }); + + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(true); + }); + + it('opens settings modal when settings button is clicked', async () => { + const { getByText, getByLabelText } = render( + + + + ); + + // Open popover and click settings button + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + await waitFor(() => { + const settingsButton = getByLabelText('Workspace Settings'); + fireEvent.click(settingsButton); + }); + + expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(true); + }); +}); diff --git a/app/src/components/navigation/WorkspaceSwitcher.tsx b/app/src/components/navigation/WorkspaceSwitcher.tsx index 74d2722..21f0f4d 100644 --- a/app/src/components/navigation/WorkspaceSwitcher.tsx +++ b/app/src/components/navigation/WorkspaceSwitcher.tsx @@ -95,6 +95,7 @@ const WorkspaceSwitcher: React.FC = () => { @@ -152,6 +153,7 @@ const WorkspaceSwitcher: React.FC = () => { variant="subtle" size="lg" color={getConditionalColor(theme, true)} + aria-label="Workspace Settings" onClick={(e) => { e.stopPropagation(); setSettingsModalVisible(true);