From 867cd0cad143e614a89f33fc5e85d8ffca1bc10d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sat, 23 Aug 2025 22:57:20 +0100 Subject: [PATCH] Add further server setup tests splitting up configuration testing from CLI --- tests/conftest.py | 8 ++ tests/test_server_cli.py | 102 ++++++++++++++++++ .../{test_server.py => test_server_config.py} | 42 ++++---- 3 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 tests/test_server_cli.py rename tests/{test_server.py => test_server_config.py} (74%) diff --git a/tests/conftest.py b/tests/conftest.py index c0643244..714bf59a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,11 @@ def mock_app(mocker): app = mocker.Mock() app.get.return_value = wrapper return app + + +@pytest.fixture +def mock_server(mock_app, mocker): + """Return a LabThings FastAPI server where .app is the mock_app fixture.""" + server = mocker.Mock() + server.app = mock_app + return server diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py new file mode 100644 index 00000000..c41e68c5 --- /dev/null +++ b/tests/test_server_cli.py @@ -0,0 +1,102 @@ +"""Test server booting and shut down.""" + +import logging + +import pytest +from fastapi import FastAPI + +# Import as ofm server to attempt to minimise confusion with server as a var in other +# functions and also FastAPI `Server`. +from openflexure_microscope_server import server as ofm_server + +from .test_server_config import FULL_CONFIG + + +def test_no_config(): + """Check that an error is thrown if no configuration is set for the microspe via CLI.""" + with pytest.raises(RuntimeError, match="No configuration"): + ofm_server.serve_from_cli([]) + + +def test_successful_start(mock_server, mocker, caplog): + """Check some basic things from the microscope setup. + + This test checks that logging is set up and that the actual function used for + handling shutdown events shuts down the mjpeg streams. + """ + # Create a mock for the camera so we can check the MJPEG streams are closed on + # shutdown. + mock_camera = mocker.Mock() + mock_server.things = {"/camera/": mock_camera} + # Mock the LabThings function that returns the server so we have a mock server + mocker.patch( + "openflexure_microscope_server.server.lt.cli.server_from_config", + return_value=mock_server, + ) + # Also mock customisation or it will try to access the hard drive and make files. + mock_customise = mocker.patch.object(ofm_server, "customise_server") + # And mock uvicorn.run as we don't want to start a server process + mock_uvicorn_run = mocker.patch("openflexure_microscope_server.server.uvicorn.run") + + # Run the mock CLI + ofm_server.serve_from_cli(["-c", FULL_CONFIG]) + + # Check that the server was customised and the run + assert mock_customise.call_count == 1 + assert mock_uvicorn_run.call_count == 1 + # Read the log config that was set + log_config = mock_uvicorn_run.call_args.kwargs["log_config"] + # Check both uvicorn loggers were set to propagate + assert log_config["loggers"]["uvicorn"]["propagate"] + assert log_config["loggers"]["uvicorn.access"]["propagate"] + + # Now Mock a shutdown signal being sent + ofm_server.Server.handle_exit(mock_server.app, "mock_signal", "mock_frame") + + # Check + assert mock_camera.kill_mjpeg_streams.call_count == 1 + + # Mock shutdown again but with the MJPEG stream throwing a BaseException + mock_camera.kill_mjpeg_streams.side_effect = BaseException("mock-54321") + + # This should log at error level but even a BaseException will not cause an error + # that stops the shutdown signal propagating + with caplog.at_level(logging.ERROR): + ofm_server.Server.handle_exit(mock_server.app, "mock_signal", "mock_frame") + # Assert there is one error log + assert len(caplog.records) == 1 + # And that it is the message raised by the kill_mjpeg_streams mock + assert str(caplog.records[0].msg) == "mock-54321" + + +def test_failed_customise(mock_server, mocker): + """Check what happens if there is an error before the server is started by uvicorn. + + This differs based on whether the --fallback flag is set. + """ + # Mock the LabThings function that returns the server so we have a mock server + mocker.patch( + "openflexure_microscope_server.server.lt.cli.server_from_config", + return_value=mock_server, + ) + # Also mock customisation or it will try to access the hard drive and make files. + mocker.patch.object( + ofm_server, "customise_server", side_effect=RuntimeError("Can't touch this") + ) + # And mock uvicorn.run as we don't want to start a server process + mock_uvicorn_run = mocker.patch("openflexure_microscope_server.server.uvicorn.run") + + # Running the mock CLI will error + with pytest.raises(RuntimeError, match="Can't touch this"): + ofm_server.serve_from_cli(["-c", FULL_CONFIG]) + + # But with the fallback flag uvicorn run will be run + ofm_server.serve_from_cli(["-c", FULL_CONFIG, "--fallback"]) + + assert mock_uvicorn_run.call_count == 1 + # Get the fallback app passed to uvicorn tun + fallback_app = mock_uvicorn_run.call_args.args[0] + # Check it really is a fastapi + assert isinstance(fallback_app, FastAPI) + # An that it has the error to display + assert str(fallback_app.labthings_error) == "Can't touch this" diff --git a/tests/test_server.py b/tests/test_server_config.py similarity index 74% rename from tests/test_server.py rename to tests/test_server_config.py index e98d66a2..ce8bb907 100644 --- a/tests/test_server.py +++ b/tests/test_server_config.py @@ -6,7 +6,9 @@ from copy import deepcopy import pytest -from openflexure_microscope_server import server +# Import as ofm server to attempt to minimise confusion with server as a var in other +# functions and also FastAPI `Server`. +from openflexure_microscope_server import server as ofm_server THIS_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.dirname(THIS_DIR) @@ -20,16 +22,16 @@ def test_monkey_patched_handle_exit(side_effect, mock_app, mocker): # 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") + mock_fastapi_server = mocker.patch.object(ofm_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) + ofm_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) + ofm_server.Server.handle_exit(*handle_exit_args) assert mock_shutdown_func.call_count == 1 assert original_mock_handle_exit.call_count == 1 @@ -39,30 +41,30 @@ def test_monkey_patched_handle_exit(side_effect, mock_app, mocker): # 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) + ofm_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 + # handle_exit wasn't. + # Note: that the non-mocked shutdown function is entirely wrapped in a + # try/except BaseExcpetion, so that handle_exit will always run. assert original_mock_handle_exit.call_count == 0 -def test_customise_server(mock_app, mocker): +def test_customise_server(mock_server, 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") + mocked_add_static = mocker.patch.object(ofm_server, "add_static_files") + mocked_v2_endpoints = mocker.patch.object(ofm_server, "add_v2_endpoints") + mocked_configure_logging = mocker.patch.object(ofm_server, "configure_logging") + mocked_retrieve_log = mocker.patch.object(ofm_server, "retrieve_log") + mocked_retrieve_log_file = mocker.patch.object(ofm_server, "retrieve_log_from_file") - # Set up a mock server - mock_server = mocker.Mock() - mock_server.app = mock_app + mock_app = mock_server.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") + ofm_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 @@ -89,7 +91,7 @@ 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 + assert ofm_server._get_scans_dir(config_dict) == expected_scan_dir def test_get_scans_dir_no_smart_scan(): @@ -100,7 +102,7 @@ def test_get_scans_dir_no_smart_scan(): # Delete smart scan del config_dict["things"]["/smart_scan/"] # No SmartScanThing, should return None - assert server._get_scans_dir(config_dict) is None + assert ofm_server._get_scans_dir(config_dict) is None def test_get_scans_dir_bad_smart_scan_config(): @@ -115,11 +117,11 @@ def test_get_scans_dir_bad_smart_scan_config(): del broken_config["things"]["/smart_scan/"]["kwargs"] # Creates an Error with pytest.raises(RuntimeError): - server._get_scans_dir(broken_config) + ofm_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) + ofm_server._get_scans_dir(broken_config)