diff --git a/testit-adapter-pytest/src/testit_adapter_pytest/plugin.py b/testit-adapter-pytest/src/testit_adapter_pytest/plugin.py index a4ad145..01016b6 100644 --- a/testit-adapter-pytest/src/testit_adapter_pytest/plugin.py +++ b/testit-adapter-pytest/src/testit_adapter_pytest/plugin.py @@ -1,10 +1,78 @@ +import re +import json +from urllib.parse import urlparse import pytest from testit_adapter_pytest.listener import TmsListener - from testit_python_commons.services import TmsPluginManager +def _adapter_mode_type(value): + if value is None: + raise ValueError("Adapter mode cannot be None! Valid modes: 0, 1, 2") + valid_modes = ['0', '1', '2'] + if value not in valid_modes: + raise ValueError(f"Unknown adapter mode '{value}'! Valid modes: {', '.join(valid_modes)}") + return value + + +def _boolean_type(value): + if value is None: + raise ValueError("Boolean value cannot be None! Must be 'true' or 'false'") + valid_values = ['true', 'false'] + if value.lower() not in valid_values: + raise ValueError(f"Invalid value '{value}'! Must be 'true' or 'false'") + return value.lower() + + +def _uuid_type(value): + if value is None: + raise ValueError("UUID cannot be None!") + uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I) + if not uuid_pattern.match(value): + raise ValueError(f"Invalid UUID format: '{value}'!") + return value + + +def _url_type(value): + if value is None: + raise ValueError("URL cannot be None!") + if not value.startswith(('http://', 'https://')): + raise ValueError(f"Invalid URL format: '{value}'!") + url = urlparse(value) + if not all([url.scheme, url.netloc]): + raise ValueError(f"Invalid URL format: '{value}'!") + return value + + +def _proxy_type(value): + if value is None: + raise ValueError("Proxy cannot be None!") + try: + proxy_dict = json.loads(value) + if not isinstance(proxy_dict, dict): + raise ValueError(f"Proxy must be a JSON object, got {type(proxy_dict).__name__}") + + valid_keys = {'http', 'https'} + for key in proxy_dict.keys(): + if key not in valid_keys: + raise ValueError(f"Invalid proxy key '{key}'! Must be 'http' or 'https'") + + for key, url in proxy_dict.items(): + if not isinstance(url, str): + raise ValueError(f"Proxy URL for '{key}' must be string, got {type(url).__name__}") + if not url.startswith(('http://', 'https://')): + raise ValueError(f"Invalid {key} proxy URL: '{url}'! Must start with http:// or https://") + parsed = urlparse(url) + if not parsed.netloc: + raise ValueError(f"Invalid {key} proxy URL: '{url}'! Missing hostname") + + return value + except json.JSONDecodeError: + raise ValueError(f"Invalid JSON format for proxy: '{value}'!") + except ValueError as e: + raise ValueError(str(e)) + def pytest_addoption(parser): parser.getgroup('testit').addoption( '--testit', @@ -16,6 +84,7 @@ def pytest_addoption(parser): '--tmsUrl', action="store", dest="set_url", + type=_url_type, metavar="https://demo.testit.software", help='Set location of the TMS instance' ) @@ -30,6 +99,7 @@ def pytest_addoption(parser): '--tmsProjectId', action="store", dest="set_project_id", + type=_uuid_type, metavar="15dbb164-c1aa-4cbf-830c-8c01ae14f4fb", help='Set project ID' ) @@ -37,6 +107,7 @@ def pytest_addoption(parser): '--tmsConfigurationId', action="store", dest="set_configuration_id", + type=_uuid_type, metavar="d354bdac-75dc-4e3d-84d4-71186c0dddfc", help='Set configuration ID' ) @@ -44,6 +115,7 @@ def pytest_addoption(parser): '--tmsTestRunId', action="store", dest="set_test_run_id", + type=_uuid_type, metavar="5236eb3f-7c05-46f9-a609-dc0278896464", help='Set test run ID (optional)' ) @@ -51,6 +123,7 @@ def pytest_addoption(parser): '--tmsProxy', action="store", dest="set_tms_proxy", + type=_proxy_type, metavar='{"http":"http://localhost:8888","https":"http://localhost:8888"}', help='Set proxy for sending requests (optional)' ) @@ -64,6 +137,7 @@ def pytest_addoption(parser): parser.getgroup('testit').addoption( '--tmsAdapterMode', action="store", + type=_adapter_mode_type, dest="set_adapter_mode", metavar="1", help=""" @@ -84,13 +158,15 @@ def pytest_addoption(parser): '--tmsCertValidation', action="store", dest="set_cert_validation", + type=_boolean_type, metavar="false", - help='Set custom name of configuration file' + help='Set certificate validation (true/false)' ) parser.getgroup('testit').addoption( '--tmsAutomaticCreationTestCases', action="store", dest="set_automatic_creation_test_cases", + type=_boolean_type, metavar="false", help=""" Set mode of automatic creation test cases (optional): @@ -102,6 +178,7 @@ def pytest_addoption(parser): '--tmsAutomaticUpdationLinksToTestCases', action="store", dest="set_automatic_updation_links_to_test_cases", + type=_boolean_type, metavar="false", help=""" Set mode of automatic updation links to test cases (optional): @@ -113,6 +190,7 @@ def pytest_addoption(parser): '--tmsImportRealtime', action="store", dest="set_import_realtime", + type=_boolean_type, metavar="false", help=""" Set mode of import type selection when launching autotests (optional): @@ -131,4 +209,4 @@ def pytest_cmdline_main(config): TmsPluginManager.get_fixture_manager()) config.pluginmanager.register(listener) - TmsPluginManager.get_plugin_manager().register(listener) + TmsPluginManager.get_plugin_manager().register(listener) \ No newline at end of file diff --git a/testit-adapter-pytest/tests/test_addoption.py b/testit-adapter-pytest/tests/test_addoption.py new file mode 100644 index 0000000..b83f656 --- /dev/null +++ b/testit-adapter-pytest/tests/test_addoption.py @@ -0,0 +1,207 @@ +import pytest +from unittest.mock import MagicMock, patch + +from testit_adapter_pytest.plugin import ( + _adapter_mode_type, + _boolean_type, + _uuid_type, + _url_type, + _proxy_type, + pytest_cmdline_main +) + + +class TestAdapterModeType: + + @pytest.mark.parametrize("valid_mode", ['0', '1', '2']) + def test_valid_modes(self, valid_mode): + assert _adapter_mode_type(valid_mode) == valid_mode + + @pytest.mark.parametrize("invalid_mode", ['3', '5', '01', '00', 'a', '', None, 'true', 'false']) + def test_invalid_modes(self, invalid_mode): + if invalid_mode is None: + with pytest.raises(ValueError, match=r"Adapter mode cannot be None! Valid modes: 0, 1, 2"): + _adapter_mode_type(invalid_mode) + else: + with pytest.raises(ValueError, match=r"Unknown adapter mode.*Valid modes: 0, 1, 2"): + _adapter_mode_type(invalid_mode) + + +class TestBooleanType: + + @pytest.mark.parametrize("input_value,expected", [ + ('true', 'true'), + ('false', 'false'), + ('TRUE', 'true'), + ('FALSE', 'false'), + ('True', 'true'), + ('False', 'false'), + ]) + def test_valid_booleans(self, input_value, expected): + assert _boolean_type(input_value) == expected + + @pytest.mark.parametrize("invalid_value", ['yes', 'no', '1', '0', 'on', 'off', 'TrueFalse', '', None]) + def test_invalid_booleans(self, invalid_value): + if invalid_value is None: + with pytest.raises(ValueError, match=r"Boolean value cannot be None! Must be 'true' or 'false'"): + _boolean_type(invalid_value) + else: + with pytest.raises(ValueError, match=r"Invalid value.*Must be 'true' or 'false'"): + _boolean_type(invalid_value) + + +class TestUUIDType: + + @pytest.mark.parametrize("valid_uuid", [ + '123e4567-e89b-12d3-a456-426614174000', + '00000000-0000-0000-0000-000000000000', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + '15dbb164-c1aa-4cbf-830c-8c01ae14f4fb', + '5236eb3f-7c05-46f9-a609-dc0278896464', + ]) + def test_valid_uuids(self, valid_uuid): + assert _uuid_type(valid_uuid) == valid_uuid + + @pytest.mark.parametrize("invalid_uuid", [ + 'not-a-uuid', + '123e4567-e89b-12d3-a456', + '123e4567-e89b-12d3-a456-42661417400Z', + '123e4567e89b12d3a456426614174000', + '123e4567-e89b-12d3-a456-4266141740000', + '', + None, + 'gfffffff-ffff-ffff-ffff-ffffffffffff', + ]) + def test_invalid_uuids(self, invalid_uuid): + if invalid_uuid is None: + with pytest.raises(ValueError, match=r"UUID cannot be None!"): + _uuid_type(invalid_uuid) + else: + with pytest.raises(ValueError, match=r"Invalid UUID format:"): + _uuid_type(invalid_uuid) + + +class TestUrlType: + + @pytest.mark.parametrize("valid_url", [ + 'https://demo.testit.software', + 'http://localhost', + 'https://example.com', + 'http://127.0.0.1:8000', + 'https://sub.domain.example.com:8080/path?query=1', + ]) + def test_valid_urls(self, valid_url): + assert _url_type(valid_url) == valid_url + + @pytest.mark.parametrize("invalid_url", [ + 'demo.testit.software', + 'https://', + 'ftp://example.com', + 'file:///etc/passwd', + 'http:/example.com', + '://example.com', + 'https://', + '', + None, + 'not a url', + ]) + def test_invalid_urls(self, invalid_url): + if invalid_url is None: + with pytest.raises(ValueError, match=r"URL cannot be None!"): + _url_type(invalid_url) + else: + with pytest.raises(ValueError, match=r"Invalid URL format:"): + _url_type(invalid_url) + + +class TestProxyType: + + @pytest.mark.parametrize("valid_proxy", [ + '{"http":"http://localhost:8888"}', + '{"https":"https://proxy.example.com:443"}', + '{"http":"http://127.0.0.1:8080","https":"https://127.0.0.1:8443"}', + '{"http":"http://user:pass@proxy:8888"}', + ]) + def test_valid_proxies(self, valid_proxy): + assert _proxy_type(valid_proxy) == valid_proxy + + @pytest.mark.parametrize("invalid_proxy,error_pattern", [ + ('not json', r"Invalid JSON format for proxy:"), + ('{"http":123}', r"Proxy URL for 'http' must be string"), + ('{"ftp":"http://proxy:8888"}', r"Invalid proxy key 'ftp'! Must be 'http' or 'https'"), + ('[]', r"Proxy must be a JSON object, got list"), + ('"string"', r"Proxy must be a JSON object, got str"), + ('42', r"Proxy must be a JSON object, got int"), + ('{"http":"not-a-url"}', r"Invalid http proxy URL: 'not-a-url'! Must start with http:// or https://"), + ('{"https":"ftp://proxy:8888"}', r"Invalid https proxy URL: 'ftp://proxy:8888'! Must start with http:// or https://"), + ('{"http":"http://","https":"https://"}', r"Invalid http proxy URL: 'http://'! Missing hostname"), + (None, r"Proxy cannot be None!"), + ]) + def test_invalid_proxies(self, invalid_proxy, error_pattern): + with pytest.raises(ValueError, match=error_pattern): + _proxy_type(invalid_proxy) + +class TestPytestCmdlineMain: + + @patch('testit_adapter_pytest.plugin.TmsListener') + @patch('testit_adapter_pytest.plugin.TmsPluginManager') + def test_cmdline_main_with_tms_report(self, mock_plugin_manager, mock_listener_class): + mock_config = MagicMock() + mock_config.option.tms_report = True + + mock_adapter_manager = MagicMock() + mock_step_manager = MagicMock() + mock_fixture_manager = MagicMock() + mock_plugin_manager.get_adapter_manager.return_value = mock_adapter_manager + mock_plugin_manager.get_step_manager.return_value = mock_step_manager + mock_plugin_manager.get_fixture_manager.return_value = mock_fixture_manager + + mock_listener = MagicMock() + mock_listener_class.return_value = mock_listener + pytest_cmdline_main(mock_config) + + mock_plugin_manager.get_adapter_manager.assert_called_once_with(mock_config.option) + mock_plugin_manager.get_step_manager.assert_called_once() + mock_plugin_manager.get_fixture_manager.assert_called_once() + mock_listener_class.assert_called_once_with( + mock_adapter_manager, + mock_step_manager, + mock_fixture_manager + ) + mock_config.pluginmanager.register.assert_called_once_with(mock_listener) + mock_plugin_manager.get_plugin_manager().register.assert_called_once_with(mock_listener) + + @patch('testit_adapter_pytest.plugin.TmsListener') + @patch('testit_adapter_pytest.plugin.TmsPluginManager') + def test_cmdline_main_without_tms_report(self, mock_plugin_manager, mock_listener_class): + + mock_config = MagicMock() + mock_config.option.tms_report = False + + pytest_cmdline_main(mock_config) + mock_plugin_manager.get_adapter_manager.assert_not_called() + mock_listener_class.assert_not_called() + mock_config.pluginmanager.register.assert_not_called() + + +class TestIntegrationWithPytest: + def test_validator_in_pytest_raises_error(self, mocker): + + from _pytest.config import Config + mock_config = Config.fromdictargs([], {}) + mock_config.option = mocker.MagicMock() + mock_config.option.tms_report = True + mock_config.option.set_adapter_mode = '5' + mock_config.option.set_url = 'https://example.com' + mock_config.option.set_private_token = 'token' + mock_config.option.set_project_id = '123e4567-e89b-12d3-a456-426614174000' + mock_config.option.set_configuration_id = '123e4567-e89b-12d3-a456-426614174000' + with pytest.raises(ValueError, match=r"Unknown adapter mode '5'! Valid modes: 0, 1, 2"): + _adapter_mode_type('5') + + def test_validator_accepts_valid_options(self): + assert _adapter_mode_type('1') == '1' + assert _url_type('https://example.com') == 'https://example.com' + assert _uuid_type('123e4567-e89b-12d3-a456-426614174000') == '123e4567-e89b-12d3-a456-426614174000' + assert _boolean_type('false') == 'false' + assert _proxy_type('{"http":"http://localhost:8888"}') == '{"http":"http://localhost:8888"}' \ No newline at end of file