diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..c0643244 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Provide fixtures that can be reused by the entire test suite. + +For more information on this pattern see: +https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files +""" + +import pytest + + +@pytest.fixture +def mock_app(mocker): + """Return a mock FastAPI app. + + This is just a mock, where the get method returns a mock. + """ + wrapper = mocker.Mock() + app = mocker.Mock() + app.get.return_value = wrapper + return app diff --git a/tests/test_serve_static_files.py b/tests/test_serve_static_files.py index 53413448..b38e2476 100644 --- a/tests/test_serve_static_files.py +++ b/tests/test_serve_static_files.py @@ -11,18 +11,6 @@ from starlette.responses import FileResponse, RedirectResponse from openflexure_microscope_server.server import serve_static_files -@pytest.fixture -def mock_app(mocker): - """Return a mock FastAPI app. - - This is just a mock, where the get method returns a mock. - """ - wrapper = mocker.Mock() - app = mocker.Mock() - app.get.return_value = wrapper - return app - - @pytest.fixture def mock_static_dir(): """Return the path of a mock static directory. diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 00000000..e98d66a2 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,125 @@ +"""Test server booting and shut down.""" + +import os +import json +from copy import deepcopy + +import pytest + +from openflexure_microscope_server import server + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(THIS_DIR) +FULL_CONFIG = os.path.join(REPO_ROOT, "ofm_config_full.json") +SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json") + + +@pytest.mark.parametrize("side_effect", [None, RuntimeError("Mock")]) +def test_monkey_patched_handle_exit(side_effect, mock_app, mocker): + """Test that handle exit is monkey_patched.""" + # Setup the same for any side_effect + # Create mocks for FastAPI.Server, and for our custom shutdown function + mock_shutdown_func = mocker.Mock(side_effect=side_effect) + mock_fastapi_server = mocker.patch.object(server, "Server") + original_mock_handle_exit = mock_fastapi_server.handle_exit + handle_exit_args = (mock_app, "mock_signal", "mock_frame") + + server.set_shutdown_function(mock_shutdown_func) + + if side_effect is None: + # In normal operation, both the custom shutdown function and the original + # handle_exit should be called + server.Server.handle_exit(*handle_exit_args) + + assert mock_shutdown_func.call_count == 1 + assert original_mock_handle_exit.call_count == 1 + # Check arguments are propagated correctly. + assert original_mock_handle_exit.call_args.args == handle_exit_args + else: + # Use an error side effect to check that our custom shutdown function is called + # before the standard `handle_exit` + with pytest.raises(RuntimeError, match="Mock"): + server.Server.handle_exit(*handle_exit_args) + # The error was raised so we know our custom function was run, check that + # handle_exit wasn't + assert original_mock_handle_exit.call_count == 0 + + +def test_customise_server(mock_app, mocker): + """Check that all server customisation stages are called.""" + # Mock everything to be called in customisation, partly setup may not be complete, + # but also to limit excess coverage reporting. + mocked_add_static = mocker.patch.object(server, "add_static_files") + mocked_v2_endpoints = mocker.patch.object(server, "add_v2_endpoints") + mocked_configure_logging = mocker.patch.object(server, "configure_logging") + mocked_retrieve_log = mocker.patch.object(server, "retrieve_log") + mocked_retrieve_log_file = mocker.patch.object(server, "retrieve_log_from_file") + + # Set up a mock server + mock_server = mocker.Mock() + mock_server.app = mock_app + # The wrapper returned for app.get so we can see what functions are decorated. + wrapper = mock_app.get.return_value + + # Finally we can run it! + server.customise_server(mock_server, "mock_log_folder", "mock_scan_folder") + + # Check each internal customisation function is called + assert mocked_add_static.call_count == 1 + assert mocked_v2_endpoints.call_count == 1 + assert mocked_configure_logging.call_count == 1 + # Check app.get adds two routes + assert mock_app.get.call_count == 2 + assert wrapper.call_count == 2 + + # Check the routes and functions are as expected. + added_routes = [call.args[0] for call in mock_app.get.call_args_list] + assert "/log/" in added_routes + assert "/logfile/" in added_routes + wrapped_functions = [call.args[0] for call in wrapper.call_args_list] + assert mocked_retrieve_log in wrapped_functions + assert mocked_retrieve_log_file + + +@pytest.mark.parametrize( + "config_file, expected_scan_dir", + [[FULL_CONFIG, "/var/openflexure/scans/"], [SIM_CONFIG, "./openflexure/scans/"]], +) +def test_get_scans_dir_ok(config_file, expected_scan_dir): + """Test the _get_scans_dir function and also check the standard config files.""" + with open(config_file, "r", encoding="utf-8") as f_obj: + config_dict = json.load(f_obj) + assert server._get_scans_dir(config_dict) == expected_scan_dir + + +def test_get_scans_dir_no_smart_scan(): + """Test _get_scans_dir with no SmartScanThing.""" + # Load the standard config dict + with open(FULL_CONFIG, "r", encoding="utf-8") as f_obj: + config_dict = json.load(f_obj) + # Delete smart scan + del config_dict["things"]["/smart_scan/"] + # No SmartScanThing, should return None + assert server._get_scans_dir(config_dict) is None + + +def test_get_scans_dir_bad_smart_scan_config(): + """Test _get_scans_dir with a SmartScanThing that doesn't set a config dir.""" + # Load the standard config dict + with open(FULL_CONFIG, "r", encoding="utf-8") as f_obj: + config_dict = json.load(f_obj) + + # Copy the dictionary + broken_config = deepcopy(config_dict) + # Delete all the smart scan kwargs + del broken_config["things"]["/smart_scan/"]["kwargs"] + # Creates an Error + with pytest.raises(RuntimeError): + server._get_scans_dir(broken_config) + + # Same thing should happen if just the scans_folder key is deleted + broken_config = deepcopy(config_dict) + del broken_config["things"]["/smart_scan/"]["kwargs"] + # Creates an Error + with pytest.raises(RuntimeError): + server._get_scans_dir(broken_config)