diff options
-rw-r--r-- | conftest.py | 80 | ||||
-rw-r--r-- | tests/helpers.py | 51 | ||||
-rw-r--r-- | tests/test_server.py | 115 |
3 files changed, 205 insertions, 41 deletions
diff --git a/conftest.py b/conftest.py index 1aef80a..6b6d1cf 100644 --- a/conftest.py +++ b/conftest.py @@ -7,5 +7,85 @@ import sys from pathlib import Path +import pytest +import pkgutil +import functools +from tempfile import TemporaryDirectory +from typing import Iterable + here = Path(__file__).resolve().parent sys.path.insert(0, str(here / 'src')) + +@pytest.fixture(autouse=True) +def no_requests(monkeypatch): + """Remove requests.sessions.Session.request for all tests.""" + monkeypatch.delattr('requests.sessions.Session.request') + +def _mock_subprocess_run(monkeypatch, where, mocked_run): + """Temporarily replace subprocess.run() with the given function.""" + class MockedSubprocess: + """Minimal mocked version of the subprocess module.""" + run = mocked_run + + monkeypatch.setattr(where, 'subprocess', MockedSubprocess) + +@pytest.fixture +def mock_subprocess_run(monkeypatch, request): + """ + Facilitate temporarily replacing subprocess.run() with a different function. + + If the 'subprocess_run' pytest marker has been used, perform the replacement + for the module-function pair supplied through it. + + Return a function that can be called to perform the same replacement in + another fixture or from inside a test function. + """ + mocker = functools.partial(_mock_subprocess_run, monkeypatch) + + marker = request.node.get_closest_marker('subprocess_run') + if marker: + where, mocked_run = marker.args + mocker(where, mocked_run) + + return mocker + +@pytest.fixture(autouse=True) +def no_gettext(monkeypatch, request): + """ + Make gettext return all strings untranslated unless we request otherwise. + """ + if request.node.get_closest_marker('enable_gettext'): + return + + import hydrilla + modules_to_process = [hydrilla] + + def add_child_modules(parent): + """ + Recursuvely collect all modules descending from 'parent' into an array. + """ + try: + load_paths = parent.__path__ + except AttributeError: + return + + for module_info in pkgutil.iter_modules(load_paths): + if module_info.name != '__main__': + __import__(f'{parent.__name__}.{module_info.name}') + modules_to_process.append(getattr(parent, module_info.name)) + add_child_modules(getattr(parent, module_info.name)) + + add_child_modules(hydrilla) + + for module in modules_to_process: + if hasattr(module, '_'): + monkeypatch.setattr(module, '_', lambda message: message) + +@pytest.fixture +def tmpdir() -> Iterable[Path]: + """ + Provide test case with a temporary directory that will be automatically + deleted after the test. + """ + with TemporaryDirectory() as tmpdir: + yield Path(tmpdir) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..df474b0 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: CC0-1.0 + +# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> +# +# Available under the terms of Creative Commons Zero v1.0 Universal. + +import re + +variable_word_re = re.compile(r'^<(.+)>$') + +def process_command(command, expected_command): + """Validate the command line and extract its variable parts (if any).""" + assert len(command) == len(expected_command) + + extracted = {} + for word, expected_word in zip(command, expected_command): + match = variable_word_re.match(expected_word) + if match: + extracted[match.group(1)] = word + else: + assert word == expected_word + + return extracted + +def run_missing_executable(command, **kwargs): + """ + Instead of running a command, raise FileNotFoundError as if its executable + was missing. + """ + raise FileNotFoundError('dummy') + +class MockedCompletedProcess: + """ + Object with some fields similar to those of subprocess.CompletedProcess. + """ + def __init__(self, args, returncode=0, + stdout='some output', stderr='some error output', + text_output=True): + """ + Initialize MockedCompletedProcess. Convert strings to bytes if needed. + """ + self.args = args + self.returncode = returncode + + if type(stdout) is str and not text_output: + stdout = stdout.encode() + if type(stderr) is str and not text_output: + stderr = stderr.encode() + + self.stdout = stdout + self.stderr = stderr diff --git a/tests/test_server.py b/tests/test_server.py index 02b9742..65ae8ce 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -43,9 +43,10 @@ from markupsafe import escape from werkzeug import Response from hydrilla import util as hydrilla_util -from hydrilla.builder import Build -from hydrilla.server import config, _version -from hydrilla.server.serve import HydrillaApp +from hydrilla.builder import build +from hydrilla.server import config, _version, serve + +from .helpers import * here = Path(__file__).resolve().parent config_path = here / 'config.json' @@ -56,13 +57,41 @@ expected_generated_by = { 'version': _version.version } -SetupMod = Optional[Callable['Setup', None]] +SetupMod = Optional[Callable[['Setup'], None]] source_files = ( 'index.json', 'hello.js', 'bye.js', 'message.js', 'README.txt', 'README.txt.license', '.reuse/dep5', 'LICENSES/CC0-1.0.txt' ) +def run_reuse(command, **kwargs): + """ + Instead of running a 'reuse' command, check if 'mock_reuse_missing' file + exists under root directory. If yes, raise FileNotFoundError as if 'reuse' + command was missing. If not, check if 'README.txt.license' file exists + in the requested directory and return zero if it does. + """ + expected = ['reuse', '--root', '<root>', + 'lint' if 'lint' in command else 'spdx'] + + root_path = Path(process_command(command, expected)['root']) + + if (root_path / 'mock_reuse_missing').exists(): + raise FileNotFoundError('dummy') + + is_reuse_compliant = (root_path / 'README.txt.license').exists() + + return MockedCompletedProcess(command, 1 - is_reuse_compliant, + stdout=f'dummy {expected[-1]} output', + text_output=kwargs.get('text')) + +@pytest.fixture +def mock_reuse(mock_subprocess_run): + """ + Mock the REUSE command when executed through subprocess.run() from serve.py. + """ + mock_subprocess_run(build, run_reuse) + class Setup: """ Facilitate preparing test malcontent directory, Hydrilla config file and the @@ -96,8 +125,8 @@ class Setup: if self._modify_before_build: self._modify_before_build(self) - build = Build(self.source_dir, self.index_json) - build.write_package_files(self.malcontent_dir) + build_obj = build.Build(self.source_dir, self.index_json) + build_obj.write_package_files(self.malcontent_dir) if self._modify_after_build: self._modify_after_build(self) @@ -115,7 +144,8 @@ class Setup: Provide app client that serves the objects from built sample package. """ if self._client is None: - app = HydrillaApp(self.config(), flask_config={'TESTING': True}) + app = serve.HydrillaApp(self.config(), + flask_config={'TESTING': True}) self._client = app.test_client() return self._client @@ -155,32 +185,23 @@ def bump_schema_v2(index_json) -> None: definition['type'] == 'resource': definition['required_mappings'] = {'identifier': 'helloapple'} -default_setup = Setup() -uuidless_setup = Setup(modify_before_build=remove_all_uuids) -schema_v2_setup = Setup(modify_before_build=bump_schema_v2) +default_setup = lambda: Setup() +uuidless_setup = lambda: Setup(modify_before_build=remove_all_uuids) +schema_v2_setup = lambda: Setup(modify_before_build=bump_schema_v2) -setups = [default_setup, uuidless_setup, schema_v2_setup] +setup_makers = [default_setup, uuidless_setup, schema_v2_setup] -def def_get(url: str) -> Response: - """Convenience wrapper for def_get()""" - return default_setup.client().get(url) - -def test_project_url() -> None: - """Fetch index.html and verify project URL from config is present there.""" - response = def_get('/') - assert b'html' in response.data - project_url = default_setup.config()['hydrilla_project_url'] - assert escape(project_url).encode() in response.data - -@pytest.mark.parametrize('setup', setups) +@pytest.mark.usefixtures('mock_reuse') +@pytest.mark.parametrize('setup_maker', setup_makers) @pytest.mark.parametrize('item_type', ['resource', 'mapping']) -def test_get_newest(setup: Setup, item_type: str) -> None: +def test_get_newest(setup_maker, item_type: str) -> None: """ Verify that GET '/{item_type}/{item_identifier}.json' returns proper definition that is also served at: GET '/{item_type}/{item_identifier}/{item_version}' """ + setup = setup_maker() response = setup.client().get(f'/{item_type}/helloapple.json') assert response.status_code == 200 definition = json.loads(response.data.decode()) @@ -191,46 +212,58 @@ def test_get_newest(setup: Setup, item_type: str) -> None: assert response.status_code == 200 assert definition == json.loads(response.data.decode()) - assert ('uuid' in definition) == (setup is not uuidless_setup) + assert ('uuid' in definition) == (setup_maker is not uuidless_setup) hydrilla_util.validator_for(f'api_{item_type}_description-1.0.1.schema.json')\ .validate(definition) +@pytest.fixture +def setup(mock_reuse): + """Prepare server test environment in the default way.""" + return default_setup() + +def test_project_url(setup) -> None: + """Fetch index.html and verify project URL from config is present there.""" + response = setup.client().get('/') + assert b'html' in response.data + project_url = setup.config()['hydrilla_project_url'] + assert escape(project_url).encode() in response.data + @pytest.mark.parametrize('item_type', ['resource', 'mapping']) -def test_get_nonexistent(item_type: str) -> None: +def test_get_nonexistent(setup, item_type: str) -> None: """ Verify that attempts to GET a JSON definition of a nonexistent item or item version result in 404. """ - response = def_get(f'/{item_type}/nonexistentapple.json') + response = setup.client().get(f'/{item_type}/nonexistentapple.json') assert response.status_code == 404 - response = def_get(f'/{item_type}/helloapple/1.2.3.999') + response = setup.client().get(f'/{item_type}/helloapple/1.2.3.999') assert response.status_code == 404 @pytest.mark.parametrize('item_type', ['resource', 'mapping']) -def test_file_refs(item_type: str) -> None: +def test_file_refs(setup, item_type: str) -> None: """ Verify that files referenced by definitions are accessible under their proper URLs and that their hashes match. """ - response = def_get(f'/{item_type}/helloapple/2021.11.10') + response = setup.client().get(f'/{item_type}/helloapple/2021.11.10') assert response.status_code == 200 definition = json.loads(response.data.decode()) for file_ref in [*definition.get('scripts', []), *definition['source_copyright']]: hash_sum = file_ref["sha256"] - response = def_get(f'/file/sha256/{hash_sum}') + response = setup.client().get(f'/file/sha256/{hash_sum}') assert response.status_code == 200 assert sha256(response.data).digest().hex() == hash_sum -def test_empty_query() -> None: +def test_empty_query(setup) -> None: """ Verify that querying mappings for URL gives an empty list when there're no mathes. """ - response = def_get(f'/query?url=https://nonexiste.nt/example') + response = setup.client().get(f'/query?url=https://nonexiste.nt/example') assert response.status_code == 200 response_object = json.loads(response.data.decode()) @@ -244,12 +277,12 @@ def test_empty_query() -> None: hydrilla_util.validator_for('api_query_result-1.0.1.schema.json')\ .validate(response_object) -def test_query() -> None: +def test_query(setup) -> None: """ Verify that querying mappings for URL gives a list with reference(s) the the matching mapping(s). """ - response = def_get(f'/query?url=https://hydrillabugs.koszko.org/') + response = setup.client().get(f'/query?url=https://hydrillabugs.koszko.org/') assert response.status_code == 200 response_object = json.loads(response.data.decode()) @@ -267,9 +300,9 @@ def test_query() -> None: hydrilla_util.validator_for('api_query_result-1.schema.json')\ .validate(response_object) -def test_source() -> None: +def test_source(setup) -> None: """Verify source descriptions are properly served.""" - response = def_get(f'/source/hello.json') + response = setup.client().get(f'/source/hello.json') assert response.status_code == 200 description = json.loads(response.data.decode()) @@ -279,18 +312,18 @@ def test_source() -> None: ['hello-message', 'helloapple', 'helloapple'] zipfile_hash = description['source_archives']['zip']['sha256'] - response = def_get(f'/source/hello.zip') + response = setup.client().get(f'/source/hello.zip') assert sha256(response.data).digest().hex() == zipfile_hash hydrilla_util.validator_for('api_source_description-1.schema.json')\ .validate(description) -def test_missing_source() -> None: +def test_missing_source(setup) -> None: """Verify requests for nonexistent sources result in 404.""" - response = def_get(f'/source/nonexistent.json') + response = setup.client().get(f'/source/nonexistent.json') assert response.status_code == 404 - response = def_get(f'/source/nonexistent.zip') + response = setup.client().get(f'/source/nonexistent.zip') assert response.status_code == 404 def test_normalize_version(): |