Add further server setup tests splitting up configuration testing from CLI

This commit is contained in:
Julian Stirling 2025-08-23 22:57:20 +01:00
parent 405299b221
commit 867cd0cad1
3 changed files with 132 additions and 20 deletions

View file

@ -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

102
tests/test_server_cli.py Normal file
View file

@ -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"

View file

@ -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)