diff --git a/app/src/components/auth/LoginPage.test.tsx b/app/src/components/auth/LoginPage.test.tsx index 40002ad..f7c4d78 100644 --- a/app/src/components/auth/LoginPage.test.tsx +++ b/app/src/components/auth/LoginPage.test.tsx @@ -38,8 +38,16 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => ( ); // Custom render function -const render = (ui: React.ReactElement) => { - return rtlRender(ui, { wrapper: TestWrapper }); +const render = async (ui: React.ReactElement) => { + const result = rtlRender(ui, { wrapper: TestWrapper }); + + // Wait for AuthProvider initialization to complete + await waitFor(() => { + // The LoginPage should be rendered (indicates AuthProvider has initialized) + expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument(); + }); + + return result; }; describe('LoginPage', () => { @@ -64,8 +72,8 @@ describe('LoginPage', () => { }); describe('Initial Render', () => { - it('renders the login form with all required elements', () => { - render(); + it('renders the login form with all required elements', async () => { + await render(); // Check title and subtitle expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument(); @@ -95,8 +103,8 @@ describe('LoginPage', () => { }); describe('Form Interaction', () => { - it('updates input values when user types', () => { - render(); + it('updates input values when user types', async () => { + await render(); const emailInput = screen.getByTestId('email-input'); const passwordInput = screen.getByTestId('password-input'); @@ -108,8 +116,8 @@ describe('LoginPage', () => { expect((passwordInput as HTMLInputElement).value).toBe('password123'); }); - it('prevents form submission with empty fields due to HTML5 validation', () => { - render(); + it('prevents form submission with empty fields due to HTML5 validation', async () => { + await render(); const submitButton = screen.getByTestId('login-button'); fireEvent.click(submitButton); @@ -132,7 +140,7 @@ describe('LoginPage', () => { }; it('calls login function with correct credentials on form submit', async () => { - render(); + await render(); fillAndSubmitForm('test@example.com', 'password123'); await waitFor(() => { @@ -151,7 +159,7 @@ describe('LoginPage', () => { }); mockApiLogin.mockReturnValue(loginPromise); - render(); + await render(); const { submitButton } = fillAndSubmitForm( 'test@example.com', 'password123' @@ -170,7 +178,7 @@ describe('LoginPage', () => { }); it('handles login success with notification', async () => { - render(); + await render(); fillAndSubmitForm('test@example.com', 'password123'); await waitFor(() => { @@ -194,7 +202,7 @@ describe('LoginPage', () => { const errorMessage = 'Invalid credentials'; mockApiLogin.mockRejectedValue(new Error(errorMessage)); - render(); + await render(); const { submitButton } = fillAndSubmitForm( 'test@example.com', 'wrongpassword' diff --git a/app/src/components/editor/MarkdownPreview.test.tsx b/app/src/components/editor/MarkdownPreview.test.tsx index da3ee85..444ee3a 100644 --- a/app/src/components/editor/MarkdownPreview.test.tsx +++ b/app/src/components/editor/MarkdownPreview.test.tsx @@ -272,7 +272,7 @@ describe('MarkdownPreview', () => { }); }); - it('handles markdown processing errors gracefully', () => { + it('handles markdown processing errors gracefully', async () => { // Mock console.error to avoid noise in test output const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -286,9 +286,12 @@ describe('MarkdownPreview', () => { /> ); - // Should still render something even if processing has issues - const markdownPreview = screen.getByTestId('markdown-preview'); - expect(markdownPreview).toBeInTheDocument(); + // Wait for async content processing to complete + await waitFor(() => { + // Should still render something even if processing has issues + const markdownPreview = screen.getByTestId('markdown-preview'); + expect(markdownPreview).toBeInTheDocument(); + }); consoleSpy.mockRestore(); }); diff --git a/app/src/components/modals/account/DeleteAccountModal.test.tsx b/app/src/components/modals/account/DeleteAccountModal.test.tsx index 104c5bb..92a4b0e 100644 --- a/app/src/components/modals/account/DeleteAccountModal.test.tsx +++ b/app/src/components/modals/account/DeleteAccountModal.test.tsx @@ -4,6 +4,7 @@ import { screen, fireEvent, waitFor, + act, } from '@testing-library/react'; import React from 'react'; import { MantineProvider } from '@mantine/core'; @@ -192,7 +193,7 @@ describe('DeleteAccountModal', () => { expect(mockOnConfirm).not.toHaveBeenCalled(); }); - it('handles rapid multiple clicks gracefully', () => { + it('handles rapid multiple clicks gracefully', async () => { render( { fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); // Multiple rapid clicks should not break the component - fireEvent.click(deleteButton); - fireEvent.click(deleteButton); - fireEvent.click(deleteButton); + act(() => { + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + }); expect(screen.getByText('Delete Account')).toBeInTheDocument(); - expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); + }); }); }); diff --git a/app/src/components/modals/account/EmailPasswordModal.test.tsx b/app/src/components/modals/account/EmailPasswordModal.test.tsx index e1a25fd..7fca059 100644 --- a/app/src/components/modals/account/EmailPasswordModal.test.tsx +++ b/app/src/components/modals/account/EmailPasswordModal.test.tsx @@ -4,6 +4,7 @@ import { screen, fireEvent, waitFor, + act, } from '@testing-library/react'; import React from 'react'; import { MantineProvider } from '@mantine/core'; @@ -218,7 +219,7 @@ describe('EmailPasswordModal', () => { }); }); - it('handles rapid multiple clicks gracefully', () => { + it('handles rapid multiple clicks gracefully', async () => { render( { fireEvent.change(passwordInput, { target: { value: 'rapidtest' } }); // Multiple rapid clicks should not break the component - fireEvent.click(confirmButton); - fireEvent.click(confirmButton); - fireEvent.click(confirmButton); + act(() => { + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); + }); - expect(screen.getByText('Confirm Password')).toBeInTheDocument(); - expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest'); + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest'); + }); }); }); diff --git a/app/src/hooks/useAdminData.test.ts b/app/src/hooks/useAdminData.test.ts index 9dcb79b..1d2153c 100644 --- a/app/src/hooks/useAdminData.test.ts +++ b/app/src/hooks/useAdminData.test.ts @@ -83,13 +83,18 @@ describe('useAdminData', () => { }); describe('stats data type', () => { - it('initializes with empty stats and loading state', () => { + it('initializes with empty stats and loading state', async () => { const { result } = renderHook(() => useAdminData('stats')); expect(result.current.data).toEqual({}); expect(result.current.loading).toBe(true); expect(result.current.error).toBeNull(); expect(typeof result.current.reload).toBe('function'); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); }); it('loads system stats successfully', async () => { @@ -148,13 +153,18 @@ describe('useAdminData', () => { }); describe('users data type', () => { - it('initializes with empty users array and loading state', () => { + it('initializes with empty users array and loading state', async () => { const { result } = renderHook(() => useAdminData('users')); expect(result.current.data).toEqual([]); expect(result.current.loading).toBe(true); expect(result.current.error).toBeNull(); expect(typeof result.current.reload).toBe('function'); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); }); it('loads users successfully', async () => { @@ -227,13 +237,18 @@ describe('useAdminData', () => { }); describe('workspaces data type', () => { - it('initializes with empty workspaces array and loading state', () => { + it('initializes with empty workspaces array and loading state', async () => { const { result } = renderHook(() => useAdminData('workspaces')); expect(result.current.data).toEqual([]); expect(result.current.loading).toBe(true); expect(result.current.error).toBeNull(); expect(typeof result.current.reload).toBe('function'); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); }); it('loads workspaces successfully', async () => { @@ -456,22 +471,69 @@ describe('useAdminData', () => { expect(result.current.data).toEqual(mockUsers); expect(mockGetUsers).toHaveBeenCalledTimes(1); }); - it('handles data type changes correctly with different initial values', () => { - const { result: statsResult } = renderHook(() => useAdminData('stats')); - const { result: usersResult } = renderHook(() => useAdminData('users')); - const { result: workspacesResult } = renderHook(() => - useAdminData('workspaces') + it('handles data type changes correctly with different initial values', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + const mockGetUsers = vi.mocked(adminApi.getUsers); + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + + mockGetSystemStats.mockResolvedValue(mockSystemStats); + mockGetUsers.mockResolvedValue(mockUsers); + mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats); + + const { result, rerender } = renderHook( + ({ type }) => useAdminData(type), + { + initialProps: { type: 'stats' as const } as { + type: 'stats' | 'users' | 'workspaces'; + }, + } ); - // Different data types should have different initial values - expect(statsResult.current.data).toEqual({}); - expect(usersResult.current.data).toEqual([]); - expect(workspacesResult.current.data).toEqual([]); + // Wait for stats to load + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.data).toEqual(mockSystemStats); + + // Change to users type - should reset to empty array and reload + act(() => { + rerender({ type: 'users' as const }); + }); + + // Data should reset to empty array immediately when type changes + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.data).toEqual(mockUsers); + + // Change to workspaces type - should reset to empty array and reload + act(() => { + rerender({ type: 'workspaces' as const }); + }); + + // Data should reset to empty array immediately when type changes + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.data).toEqual(mockWorkspaceStats); + + // Verify correct API calls were made + expect(mockGetSystemStats).toHaveBeenCalledTimes(1); + expect(mockGetUsers).toHaveBeenCalledTimes(1); + expect(mockGetWorkspaces).toHaveBeenCalledTimes(1); }); }); describe('function stability', () => { - it('maintains stable reload function reference', () => { + it('maintains stable reload function reference', async () => { const { result, rerender } = renderHook(() => useAdminData('stats')); const initialReload = result.current.reload; @@ -479,6 +541,11 @@ describe('useAdminData', () => { rerender(); expect(result.current.reload).toBe(initialReload); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); }); }); diff --git a/app/src/hooks/useAdminData.ts b/app/src/hooks/useAdminData.ts index 730b4ad..519aa9a 100644 --- a/app/src/hooks/useAdminData.ts +++ b/app/src/hooks/useAdminData.ts @@ -28,7 +28,7 @@ export const useAdminData = ( type: T ): AdminDataResult => { // Initialize with the appropriate empty type - const getInitialData = (): AdminData => { + const getInitialData = useCallback((): AdminData => { if (type === 'stats') { return {} as SystemStats as AdminData; } else if (type === 'workspaces') { @@ -38,12 +38,18 @@ export const useAdminData = ( } else { return [] as unknown as AdminData; } - }; + }, [type]); const [data, setData] = useState>(getInitialData()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Reset data when type changes + useEffect(() => { + setData(getInitialData()); + setError(null); + }, [type, getInitialData]); + const loadData = useCallback(async () => { setLoading(true); setError(null); diff --git a/app/src/hooks/useFileContent.test.ts b/app/src/hooks/useFileContent.test.ts index 9ac10ab..fade57c 100644 --- a/app/src/hooks/useFileContent.test.ts +++ b/app/src/hooks/useFileContent.test.ts @@ -439,9 +439,23 @@ describe('useFileContent', () => { }); describe('function stability', () => { - it('maintains stable function references across re-renders and workspace changes', () => { + it('maintains stable function references across re-renders and workspace changes', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + // Mock API calls for both workspaces + mockGetFileContent + .mockResolvedValueOnce('Content from workspace 1') + .mockResolvedValueOnce('Content from workspace 2'); + mockIsImageFile.mockReturnValue(false); + const { result, rerender } = renderHook(() => useFileContent('test.md')); + // Wait for initial load to complete + await waitFor(() => { + expect(result.current.content).toBe('Content from workspace 1'); + }); + const initialFunctions = { setContent: result.current.setContent, setHasUnsavedChanges: result.current.setHasUnsavedChanges, @@ -464,14 +478,21 @@ describe('useFileContent', () => { ); // Change workspace - mockWorkspaceData.currentWorkspace = { - id: 2, - name: 'different-workspace', - }; + act(() => { + mockWorkspaceData.currentWorkspace = { + id: 2, + name: 'different-workspace', + }; + }); rerender(); - // Functions should still be stable + // Wait for content to load from new workspace + await waitFor(() => { + expect(result.current.content).toBe('Content from workspace 2'); + }); + + // Functions should still be stable (except handleContentChange which depends on originalContent) expect(result.current.setContent).toBe(initialFunctions.setContent); expect(result.current.setHasUnsavedChanges).toBe( initialFunctions.setHasUnsavedChanges @@ -479,7 +500,8 @@ describe('useFileContent', () => { expect(result.current.loadFileContent).not.toBe( initialFunctions.loadFileContent ); - expect(result.current.handleContentChange).toBe( + // handleContentChange depends on originalContent which changes when workspace changes + expect(result.current.handleContentChange).not.toBe( initialFunctions.handleContentChange ); });