diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e7a37fa..c6f1469 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -30,21 +30,17 @@ class TestAuthentication: assert data['success'] is False assert 'message' in data - def test_login_missing_username(self, client): - """Test login with missing username""" - response = client.post('/api/auth/login', json={ - 'password': 'admin123' - }) - - assert response.status_code == 401 - data = response.get_json() - assert data['success'] is False - - def test_login_missing_password(self, client): - """Test login with missing password""" - response = client.post('/api/auth/login', json={ - 'username': 'admin' - }) + @pytest.mark.parametrize("payload,description", [ + ({'password': 'admin123'}, 'missing username'), + ({'username': 'admin'}, 'missing password'), + ({}, 'missing both username and password'), + ({'username': ''}, 'empty username'), + ({'password': ''}, 'empty password'), + ({'username': '', 'password': ''}, 'both fields empty'), + ]) + def test_login_missing_or_empty_fields(self, client, payload, description): + """Test login with missing or empty fields""" + response = client.post('/api/auth/login', json=payload) assert response.status_code == 401 data = response.get_json() diff --git a/backend/tests/test_containers.py b/backend/tests/test_containers.py index 7a78bef..780ce49 100644 --- a/backend/tests/test_containers.py +++ b/backend/tests/test_containers.py @@ -54,47 +54,30 @@ class TestContainerEndpoints: data = response.get_json() assert 'error' in data + @pytest.mark.parametrize("action,method,container_method,extra_kwargs", [ + ('start', 'post', 'start', {}), + ('stop', 'post', 'stop', {}), + ('restart', 'post', 'restart', {}), + ]) @patch('utils.container_helpers.get_docker_client') - def test_start_container_success(self, mock_get_client, client, auth_headers): - """Test starting a container""" + def test_container_action_success(self, mock_get_client, client, auth_headers, action, method, container_method, extra_kwargs): + """Test container actions (start, stop, restart)""" mock_container = MagicMock() mock_client = MagicMock() mock_client.containers.get.return_value = mock_container mock_get_client.return_value = mock_client - response = client.post('/api/containers/abc123/start', headers=auth_headers) + response = getattr(client, method)(f'/api/containers/abc123/{action}', headers=auth_headers) assert response.status_code == 200 data = response.get_json() assert data['success'] is True - mock_container.start.assert_called_once() - @patch('utils.container_helpers.get_docker_client') - def test_stop_container_success(self, mock_get_client, client, auth_headers): - """Test stopping a container""" - mock_container = MagicMock() - mock_client = MagicMock() - mock_client.containers.get.return_value = mock_container - mock_get_client.return_value = mock_client - - response = client.post('/api/containers/abc123/stop', headers=auth_headers) - assert response.status_code == 200 - data = response.get_json() - assert data['success'] is True - mock_container.stop.assert_called_once() - - @patch('utils.container_helpers.get_docker_client') - def test_restart_container_success(self, mock_get_client, client, auth_headers): - """Test restarting a container""" - mock_container = MagicMock() - mock_client = MagicMock() - mock_client.containers.get.return_value = mock_container - mock_get_client.return_value = mock_client - - response = client.post('/api/containers/abc123/restart', headers=auth_headers) - assert response.status_code == 200 - data = response.get_json() - assert data['success'] is True - mock_container.restart.assert_called_once() + # Verify the correct container method was called + container_action = getattr(mock_container, container_method) + if extra_kwargs: + container_action.assert_called_once_with(**extra_kwargs) + else: + container_action.assert_called_once() @patch('utils.container_helpers.get_docker_client') def test_remove_container_success(self, mock_get_client, client, auth_headers): diff --git a/frontend/components/Dashboard/__tests__/DashboardHeader.test.tsx b/frontend/components/Dashboard/__tests__/DashboardHeader.test.tsx index 5f4f520..41c1aa6 100644 --- a/frontend/components/Dashboard/__tests__/DashboardHeader.test.tsx +++ b/frontend/components/Dashboard/__tests__/DashboardHeader.test.tsx @@ -10,10 +10,15 @@ describe('DashboardHeader', () => { jest.clearAllMocks(); }); - it('should render container count on desktop', () => { + it.each([ + [0, /0 active containers/i], + [1, /1 active container/i], + [5, /5 active containers/i], + [42, /42 active containers/i], + ])('should render %i containers with correct pluralization on desktop', (count, expectedText) => { render( { /> ); - expect(screen.getByText(/5 active containers/i)).toBeInTheDocument(); - }); - - it('should render singular "container" for count of 1 on desktop', () => { - render( - - ); - - expect(screen.getByText(/1 active container/i)).toBeInTheDocument(); + expect(screen.getByText(expectedText)).toBeInTheDocument(); }); it('should not show container count on mobile', () => { diff --git a/frontend/components/__tests__/ContainerCard.test.tsx b/frontend/components/__tests__/ContainerCard.test.tsx index 7820e96..314fdf3 100644 --- a/frontend/components/__tests__/ContainerCard.test.tsx +++ b/frontend/components/__tests__/ContainerCard.test.tsx @@ -55,48 +55,25 @@ describe('ContainerCard', () => { expect(screen.getByText('2 hours')).toBeInTheDocument(); }); - it('should show correct border color for running status', () => { + it.each([ + ['running', '#38b2ac'], + ['stopped', '#718096'], + ['paused', '#ecc94b'], + ['exited', '#718096'], // fallback to stopped color + ['unknown', '#718096'], // fallback to stopped color + ])('should show correct border color for %s status', (status, expectedColor) => { + const containerWithStatus = { ...mockContainer, status }; + const { container } = render( ); const card = container.querySelector('.MuiCard-root'); - expect(card).toHaveStyle({ borderColor: '#38b2ac' }); - }); - - it('should show correct border color for stopped status', () => { - const stoppedContainer = { ...mockContainer, status: 'stopped' }; - - const { container } = render( - - ); - - const card = container.querySelector('.MuiCard-root'); - expect(card).toHaveStyle({ borderColor: '#718096' }); - }); - - it('should use default border color for unknown status', () => { - const unknownContainer = { ...mockContainer, status: 'unknown' }; - - const { container } = render( - - ); - - const card = container.querySelector('.MuiCard-root'); - // Should fallback to 'stopped' color (#718096) - expect(card).toHaveStyle({ borderColor: '#718096' }); + expect(card).toHaveStyle({ borderColor: expectedColor }); }); it('should call useContainerActions with correct parameters', () => { diff --git a/frontend/components/__tests__/LoginForm.test.tsx b/frontend/components/__tests__/LoginForm.test.tsx index 15037e0..b2bc23f 100644 --- a/frontend/components/__tests__/LoginForm.test.tsx +++ b/frontend/components/__tests__/LoginForm.test.tsx @@ -46,22 +46,18 @@ describe('LoginForm', () => { expect(screen.getByRole('button', { name: /access dashboard/i })).toBeInTheDocument(); }); - it('updates username input on change', () => { + it.each([ + ['username', /username/i, 'testuser'], + ['username', /username/i, 'admin'], + ['password', /password/i, 'testpass'], + ['password', /password/i, 'secure123'], + ])('updates %s input to "%s" on change', (fieldType, labelRegex, value) => { renderWithProvider(); - const usernameInput = screen.getByLabelText(/username/i) as HTMLInputElement; - fireEvent.change(usernameInput, { target: { value: 'testuser' } }); + const input = screen.getByLabelText(labelRegex) as HTMLInputElement; + fireEvent.change(input, { target: { value } }); - expect(usernameInput.value).toBe('testuser'); - }); - - it('updates password input on change', () => { - renderWithProvider(); - - const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement; - fireEvent.change(passwordInput, { target: { value: 'testpass' } }); - - expect(passwordInput.value).toBe('testpass'); + expect(input.value).toBe(value); }); it('shows loading text when loading', () => { diff --git a/frontend/components/__tests__/TerminalModal.test.tsx b/frontend/components/__tests__/TerminalModal.test.tsx index f4976bf..37009dc 100644 --- a/frontend/components/__tests__/TerminalModal.test.tsx +++ b/frontend/components/__tests__/TerminalModal.test.tsx @@ -384,4 +384,66 @@ describe('TerminalModal', () => { // Shift+Enter should not execute (allows multi-line input) expect(mockUseSimpleTerminal).toHaveBeenCalledWith('container123'); }); + + it('should call reset when closing FallbackNotification', () => { + const mockReset = jest.fn(); + + mockUseTerminalModalState.mockReturnValue({ + ...defaultModalState, + showFallbackNotification: true, + fallbackReason: 'Test reason', + mode: 'simple', + reset: mockReset, + }); + + render( + + ); + + // FallbackNotification onClose should call modalState.reset() + // This is passed as a prop to FallbackNotification component + expect(mockUseTerminalModalState).toHaveBeenCalled(); + }); + + it('should apply minHeight/maxHeight based on isMobile', () => { + mockUseTerminalModalState.mockReturnValue({ + ...defaultModalState, + isMobile: false, + }); + + const { rerender } = render( + + ); + + // Dialog should be rendered with desktop dimensions + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Change to mobile + mockUseTerminalModalState.mockReturnValue({ + ...defaultModalState, + isMobile: true, + }); + + rerender( + + ); + + // Dialog should now use mobile dimensions (fullScreen) + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); }); diff --git a/frontend/lib/hooks/__tests__/useSimpleTerminal.test.tsx b/frontend/lib/hooks/__tests__/useSimpleTerminal.test.tsx index 95e9c4c..7c1d8c9 100644 --- a/frontend/lib/hooks/__tests__/useSimpleTerminal.test.tsx +++ b/frontend/lib/hooks/__tests__/useSimpleTerminal.test.tsx @@ -61,25 +61,16 @@ describe('useSimpleTerminal', () => { expect(result.current.command).toBe(''); }); - it('should not execute empty command', async () => { + it.each([ + ['empty command', ''], + ['whitespace-only command', ' '], + ['tab-only command', '\t\t'], + ['newline command', '\n'], + ])('should not execute %s', async (description, command) => { const { result } = renderHook(() => useSimpleTerminal(containerId)); act(() => { - result.current.setCommand(''); - }); - - await act(async () => { - await result.current.executeCommand(); - }); - - expect((mockApiClient as any).executeCommand).not.toHaveBeenCalled(); - }); - - it('should not execute whitespace-only command', async () => { - const { result } = renderHook(() => useSimpleTerminal(containerId)); - - act(() => { - result.current.setCommand(' '); + result.current.setCommand(command); }); await act(async () => { @@ -276,4 +267,54 @@ describe('useSimpleTerminal', () => { // The hook state is updated to the NEW workdir from the result expect(result.current.workdir).toBe('/home/user'); }); + + it('should handle outputRef for auto-scrolling', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: 'test output', + exit_code: 0, + workdir: '/', + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + // Create a mock ref + const mockDiv = document.createElement('div'); + Object.defineProperty(mockDiv, 'scrollHeight', { value: 1000, writable: true }); + Object.defineProperty(mockDiv, 'scrollTop', { value: 0, writable: true }); + + act(() => { + result.current.outputRef.current = mockDiv; + result.current.setCommand('echo test'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + // The useEffect should have run and auto-scrolled + expect(result.current.output).toHaveLength(2); + }); + + it('should not update workdir when result has no workdir', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: 'test', + exit_code: 0, + // No workdir in response + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('echo test'); + }); + + const initialWorkdir = result.current.workdir; + + await act(async () => { + await result.current.executeCommand(); + }); + + // Workdir should remain unchanged + expect(result.current.workdir).toBe(initialWorkdir); + }); });