Update tests to handle asynchronous loading and initialization states across multiple components

This commit is contained in:
2025-07-06 01:29:55 +02:00
parent 7a31bd4c76
commit e5c34c25d7
7 changed files with 164 additions and 49 deletions

View File

@@ -38,8 +38,16 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
); );
// Custom render function // Custom render function
const render = (ui: React.ReactElement) => { const render = async (ui: React.ReactElement) => {
return rtlRender(ui, { wrapper: TestWrapper }); 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', () => { describe('LoginPage', () => {
@@ -64,8 +72,8 @@ describe('LoginPage', () => {
}); });
describe('Initial Render', () => { describe('Initial Render', () => {
it('renders the login form with all required elements', () => { it('renders the login form with all required elements', async () => {
render(<LoginPage />); await render(<LoginPage />);
// Check title and subtitle // Check title and subtitle
expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument(); expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument();
@@ -95,8 +103,8 @@ describe('LoginPage', () => {
}); });
describe('Form Interaction', () => { describe('Form Interaction', () => {
it('updates input values when user types', () => { it('updates input values when user types', async () => {
render(<LoginPage />); await render(<LoginPage />);
const emailInput = screen.getByTestId('email-input'); const emailInput = screen.getByTestId('email-input');
const passwordInput = screen.getByTestId('password-input'); const passwordInput = screen.getByTestId('password-input');
@@ -108,8 +116,8 @@ describe('LoginPage', () => {
expect((passwordInput as HTMLInputElement).value).toBe('password123'); expect((passwordInput as HTMLInputElement).value).toBe('password123');
}); });
it('prevents form submission with empty fields due to HTML5 validation', () => { it('prevents form submission with empty fields due to HTML5 validation', async () => {
render(<LoginPage />); await render(<LoginPage />);
const submitButton = screen.getByTestId('login-button'); const submitButton = screen.getByTestId('login-button');
fireEvent.click(submitButton); fireEvent.click(submitButton);
@@ -132,7 +140,7 @@ describe('LoginPage', () => {
}; };
it('calls login function with correct credentials on form submit', async () => { it('calls login function with correct credentials on form submit', async () => {
render(<LoginPage />); await render(<LoginPage />);
fillAndSubmitForm('test@example.com', 'password123'); fillAndSubmitForm('test@example.com', 'password123');
await waitFor(() => { await waitFor(() => {
@@ -151,7 +159,7 @@ describe('LoginPage', () => {
}); });
mockApiLogin.mockReturnValue(loginPromise); mockApiLogin.mockReturnValue(loginPromise);
render(<LoginPage />); await render(<LoginPage />);
const { submitButton } = fillAndSubmitForm( const { submitButton } = fillAndSubmitForm(
'test@example.com', 'test@example.com',
'password123' 'password123'
@@ -170,7 +178,7 @@ describe('LoginPage', () => {
}); });
it('handles login success with notification', async () => { it('handles login success with notification', async () => {
render(<LoginPage />); await render(<LoginPage />);
fillAndSubmitForm('test@example.com', 'password123'); fillAndSubmitForm('test@example.com', 'password123');
await waitFor(() => { await waitFor(() => {
@@ -194,7 +202,7 @@ describe('LoginPage', () => {
const errorMessage = 'Invalid credentials'; const errorMessage = 'Invalid credentials';
mockApiLogin.mockRejectedValue(new Error(errorMessage)); mockApiLogin.mockRejectedValue(new Error(errorMessage));
render(<LoginPage />); await render(<LoginPage />);
const { submitButton } = fillAndSubmitForm( const { submitButton } = fillAndSubmitForm(
'test@example.com', 'test@example.com',
'wrongpassword' 'wrongpassword'

View File

@@ -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 // Mock console.error to avoid noise in test output
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -286,9 +286,12 @@ describe('MarkdownPreview', () => {
/> />
); );
// Wait for async content processing to complete
await waitFor(() => {
// Should still render something even if processing has issues // Should still render something even if processing has issues
const markdownPreview = screen.getByTestId('markdown-preview'); const markdownPreview = screen.getByTestId('markdown-preview');
expect(markdownPreview).toBeInTheDocument(); expect(markdownPreview).toBeInTheDocument();
});
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });

View File

@@ -4,6 +4,7 @@ import {
screen, screen,
fireEvent, fireEvent,
waitFor, waitFor,
act,
} from '@testing-library/react'; } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
@@ -192,7 +193,7 @@ describe('DeleteAccountModal', () => {
expect(mockOnConfirm).not.toHaveBeenCalled(); expect(mockOnConfirm).not.toHaveBeenCalled();
}); });
it('handles rapid multiple clicks gracefully', () => { it('handles rapid multiple clicks gracefully', async () => {
render( render(
<DeleteAccountModal <DeleteAccountModal
opened={true} opened={true}
@@ -207,14 +208,18 @@ describe('DeleteAccountModal', () => {
fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); fireEvent.change(passwordInput, { target: { value: 'testpassword' } });
// Multiple rapid clicks should not break the component // Multiple rapid clicks should not break the component
act(() => {
fireEvent.click(deleteButton); fireEvent.click(deleteButton);
fireEvent.click(deleteButton); fireEvent.click(deleteButton);
fireEvent.click(deleteButton); fireEvent.click(deleteButton);
});
expect(screen.getByText('Delete Account')).toBeInTheDocument(); expect(screen.getByText('Delete Account')).toBeInTheDocument();
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); expect(mockOnConfirm).toHaveBeenCalledWith('testpassword');
}); });
}); });
});
describe('Accessibility and Security', () => { describe('Accessibility and Security', () => {
it('has proper form structure and security attributes', () => { it('has proper form structure and security attributes', () => {

View File

@@ -4,6 +4,7 @@ import {
screen, screen,
fireEvent, fireEvent,
waitFor, waitFor,
act,
} from '@testing-library/react'; } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; 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( render(
<EmailPasswordModal <EmailPasswordModal
opened={true} opened={true}
@@ -234,14 +235,17 @@ describe('EmailPasswordModal', () => {
fireEvent.change(passwordInput, { target: { value: 'rapidtest' } }); fireEvent.change(passwordInput, { target: { value: 'rapidtest' } });
// Multiple rapid clicks should not break the component // Multiple rapid clicks should not break the component
act(() => {
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
});
expect(screen.getByText('Confirm Password')).toBeInTheDocument(); await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest'); expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest');
}); });
}); });
});
describe('Accessibility and Security', () => { describe('Accessibility and Security', () => {
it('has proper form structure and security attributes', () => { it('has proper form structure and security attributes', () => {

View File

@@ -83,13 +83,18 @@ describe('useAdminData', () => {
}); });
describe('stats data type', () => { 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')); const { result } = renderHook(() => useAdminData('stats'));
expect(result.current.data).toEqual({}); expect(result.current.data).toEqual({});
expect(result.current.loading).toBe(true); expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
expect(typeof result.current.reload).toBe('function'); 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 () => { it('loads system stats successfully', async () => {
@@ -148,13 +153,18 @@ describe('useAdminData', () => {
}); });
describe('users data type', () => { 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')); const { result } = renderHook(() => useAdminData('users'));
expect(result.current.data).toEqual([]); expect(result.current.data).toEqual([]);
expect(result.current.loading).toBe(true); expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
expect(typeof result.current.reload).toBe('function'); 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 () => { it('loads users successfully', async () => {
@@ -227,13 +237,18 @@ describe('useAdminData', () => {
}); });
describe('workspaces data type', () => { 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')); const { result } = renderHook(() => useAdminData('workspaces'));
expect(result.current.data).toEqual([]); expect(result.current.data).toEqual([]);
expect(result.current.loading).toBe(true); expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
expect(typeof result.current.reload).toBe('function'); 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 () => { it('loads workspaces successfully', async () => {
@@ -456,22 +471,69 @@ describe('useAdminData', () => {
expect(result.current.data).toEqual(mockUsers); expect(result.current.data).toEqual(mockUsers);
expect(mockGetUsers).toHaveBeenCalledTimes(1); expect(mockGetUsers).toHaveBeenCalledTimes(1);
}); });
it('handles data type changes correctly with different initial values', () => { it('handles data type changes correctly with different initial values', async () => {
const { result: statsResult } = renderHook(() => useAdminData('stats')); const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
const { result: usersResult } = renderHook(() => useAdminData('users')); const mockGetUsers = vi.mocked(adminApi.getUsers);
const { result: workspacesResult } = renderHook(() => const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces);
useAdminData('workspaces')
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 // Wait for stats to load
expect(statsResult.current.data).toEqual({}); await waitFor(() => {
expect(usersResult.current.data).toEqual([]); expect(result.current.loading).toBe(false);
expect(workspacesResult.current.data).toEqual([]); });
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', () => { describe('function stability', () => {
it('maintains stable reload function reference', () => { it('maintains stable reload function reference', async () => {
const { result, rerender } = renderHook(() => useAdminData('stats')); const { result, rerender } = renderHook(() => useAdminData('stats'));
const initialReload = result.current.reload; const initialReload = result.current.reload;
@@ -479,6 +541,11 @@ describe('useAdminData', () => {
rerender(); rerender();
expect(result.current.reload).toBe(initialReload); expect(result.current.reload).toBe(initialReload);
// Wait for the hook to complete its async initialization
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
}); });
}); });

View File

@@ -28,7 +28,7 @@ export const useAdminData = <T extends AdminDataType>(
type: T type: T
): AdminDataResult<T> => { ): AdminDataResult<T> => {
// Initialize with the appropriate empty type // Initialize with the appropriate empty type
const getInitialData = (): AdminData<T> => { const getInitialData = useCallback((): AdminData<T> => {
if (type === 'stats') { if (type === 'stats') {
return {} as SystemStats as AdminData<T>; return {} as SystemStats as AdminData<T>;
} else if (type === 'workspaces') { } else if (type === 'workspaces') {
@@ -38,12 +38,18 @@ export const useAdminData = <T extends AdminDataType>(
} else { } else {
return [] as unknown as AdminData<T>; return [] as unknown as AdminData<T>;
} }
}; }, [type]);
const [data, setData] = useState<AdminData<T>>(getInitialData()); const [data, setData] = useState<AdminData<T>>(getInitialData());
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Reset data when type changes
useEffect(() => {
setData(getInitialData());
setError(null);
}, [type, getInitialData]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);

View File

@@ -439,9 +439,23 @@ describe('useFileContent', () => {
}); });
describe('function stability', () => { 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')); 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 = { const initialFunctions = {
setContent: result.current.setContent, setContent: result.current.setContent,
setHasUnsavedChanges: result.current.setHasUnsavedChanges, setHasUnsavedChanges: result.current.setHasUnsavedChanges,
@@ -464,14 +478,21 @@ describe('useFileContent', () => {
); );
// Change workspace // Change workspace
act(() => {
mockWorkspaceData.currentWorkspace = { mockWorkspaceData.currentWorkspace = {
id: 2, id: 2,
name: 'different-workspace', name: 'different-workspace',
}; };
});
rerender(); 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.setContent).toBe(initialFunctions.setContent);
expect(result.current.setHasUnsavedChanges).toBe( expect(result.current.setHasUnsavedChanges).toBe(
initialFunctions.setHasUnsavedChanges initialFunctions.setHasUnsavedChanges
@@ -479,7 +500,8 @@ describe('useFileContent', () => {
expect(result.current.loadFileContent).not.toBe( expect(result.current.loadFileContent).not.toBe(
initialFunctions.loadFileContent initialFunctions.loadFileContent
); );
expect(result.current.handleContentChange).toBe( // handleContentChange depends on originalContent which changes when workspace changes
expect(result.current.handleContentChange).not.toBe(
initialFunctions.handleContentChange initialFunctions.handleContentChange
); );
}); });