Merge branch 'issue-1382' into 'master'

Fix a websockets issue, and an OpenLP internal server issue

Closes #1618 and #1382

See merge request openlp/openlp!643
This commit is contained in:
Raoul Snyman 2023-08-13 21:49:06 +00:00
commit 8d49dbf042
4 changed files with 311 additions and 54 deletions

View File

@ -23,16 +23,14 @@ The :mod:`websockets` module contains the websockets server. This is a server us
changes from within OpenLP. It uses JSON to communicate with the remotes.
"""
import asyncio
import dataclasses
from dataclasses import dataclass
import json
import logging
from typing import Optional, Union
import uuid
from dataclasses import asdict, dataclass
from typing import Optional, Union
from PyQt5 import QtCore
import time
from PyQt5 import QtCore
from websockets import serve
from openlp.core.common.mixins import LogMixin, RegistryProperties
@ -86,10 +84,11 @@ class WebSocketWorker(ThreadWorker, RegistryProperties, LogMixin):
log.debug('WebSocket server started on {addr}:{port}'.format(addr=address, port=port))
except Exception:
log.exception('Failed to start WebSocket server')
loop += 1
time.sleep(0.1)
if not self.server and loop > 3:
log.error('Unable to start WebSocket server {addr}:{port}, giving up'.format(addr=address, port=port))
break
loop += 1
if self.server:
# If the websocket server exists, start listening
try:
@ -184,6 +183,10 @@ class WebSocketWorker(ThreadWorker, RegistryProperties, LogMixin):
Inserts the state in each connection message queue
:param state: OpenLP State
"""
if not self.event_loop.is_running():
# Sometimes the event loop doesn't run when we call this method -- probably because it is shutting down
# See https://gitlab.com/openlp/openlp/-/issues/1618
return
for queue in self.state_queues.copy():
self.event_loop.call_soon_threadsafe(queue.put_nowait, state)
@ -192,8 +195,12 @@ class WebSocketWorker(ThreadWorker, RegistryProperties, LogMixin):
Inserts the message in each connection message queue
:param state: OpenLP State
"""
if not self.event_loop.is_running():
# Sometimes the event loop doesn't run when we call this method -- probably because it is shutting down
# See https://gitlab.com/openlp/openlp/-/issues/1618
return
for queue in self.message_queues.copy():
self.event_loop.call_soon_threadsafe(queue.put_nowait, dataclasses.asdict(message))
self.event_loop.call_soon_threadsafe(queue.put_nowait, asdict(message))
class WebSocketServer(RegistryBase, RegistryProperties, QtCore.QObject, LogMixin):
@ -261,8 +268,7 @@ def websocket_send_message(message: WebSocketMessage):
"""
Sends a message over websocket to all connected clients.
"""
ws: WebSocketServer = Registry().get("web_socket_server")
if ws:
if ws := Registry().get("web_socket_server"):
ws.send_message(message)
return True
return False

View File

@ -19,12 +19,16 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
from pathlib import Path
from typing import Optional
from PyQt5 import QtCore, QtNetwork
from openlp.core.common.mixins import LogMixin
from openlp.core.common.registry import Registry
# The maximum amount of time to wait before giving up, 120s
MAX_WAIT_TIME_MS = 120000
class Server(QtCore.QObject, LogMixin):
"""
@ -34,10 +38,12 @@ class Server(QtCore.QObject, LogMixin):
def __init__(self):
super(Server, self).__init__()
self.out_socket = QtNetwork.QLocalSocket()
self.server = None
self.server: Optional[QtNetwork.QLocalServer] = None
self.file_name: Optional[Path | str] = None
self.id = 'OpenLPDual'
self._ms_waited = 0
def is_another_instance_running(self):
def is_another_instance_running(self) -> bool:
"""
Check the see if an other instance is running
:return: True of False
@ -46,7 +52,7 @@ class Server(QtCore.QObject, LogMixin):
self.out_socket.connectToServer(self.id)
return self.out_socket.waitForConnected()
def post_to_server(self, args):
def post_to_server(self, args: list):
"""
Post the file name to the over instance
:param args: The passed arguments including maybe a file name
@ -62,7 +68,7 @@ class Server(QtCore.QObject, LogMixin):
raise Exception(str(self.out_socket.errorString()))
self.out_socket.disconnectFromServer()
def start_server(self):
def start_server(self) -> bool:
"""
Start the socket server to allow inter app communication
:return:
@ -90,15 +96,23 @@ class Server(QtCore.QObject, LogMixin):
self.in_stream.setCodec('UTF-8')
self.in_socket.readyRead.connect(self._on_ready_read)
@QtCore.pyqtSlot()
def _on_ready_read(self):
"""
Read a record passed to the server and pass to the service manager to handle
:return:
"""
msg = self.in_stream.readLine()
if msg:
self.log_debug("socket msg = " + msg)
Registry().get('service_manager').load_service(Path(msg))
if not self.file_name:
self.file_name = self.in_stream.readLine()
self.log_debug(f'file name = "{self.file_name}"')
if service_manager := Registry().get('service_manager'):
service_manager.load_service(Path(self.file_name))
elif self._ms_waited > MAX_WAIT_TIME_MS:
self.log_error('OpenLP is taking too long to start up, abandoning file load')
else:
self.log_info('Service manager is not loaded yet, waiting 500ms')
self._ms_waited += 500
QtCore.QTimer.singleShot(500, self._on_ready_read)
def close_server(self):
"""

View File

@ -22,26 +22,27 @@
Functional tests to test the Http Server Class.
"""
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
from openlp.core.api.websocketspoll import WebSocketPoller
from openlp.core.api.websockets import WebSocketMessage, WebSocketWorker, WebSocketServer, websocket_send_message
from openlp.core.common.registry import Registry
from openlp.core.common.settings import Settings
@pytest.fixture
def poller(settings):
def poller(settings: Settings):
poll = WebSocketPoller()
yield poll
@pytest.fixture
def worker(settings):
def worker(settings: Settings):
worker = WebSocketWorker()
yield worker
def test_poller_get_state(poller, settings):
def test_poller_get_state(poller: WebSocketPoller, settings: Settings):
"""
Test the get_state function returns the correct JSON
"""
@ -71,7 +72,7 @@ def test_poller_get_state(poller, settings):
assert poll_json['results']['item'] == '23-34-45', 'The item return value should match 23-34-45'
def test_poller_event_attach(poller, settings):
def test_poller_event_attach(poller: WebSocketPoller, settings: Settings):
"""
Test the event attach of WebSocketPoller
"""
@ -95,7 +96,7 @@ def test_poller_event_attach(poller, settings):
slidecontroller_changed_connect.assert_called_once()
def test_poller_on_change_emit(poller, settings):
def test_poller_on_change_emit(poller: WebSocketPoller, settings: Settings):
"""
Test the change event emission of WebSocketPoller
"""
@ -112,7 +113,7 @@ def test_poller_on_change_emit(poller, settings):
poller_changed_emit.assert_called_once()
def test_poller_get_state_is_never_none(poller):
def test_poller_get_state_is_never_none(poller: WebSocketPoller):
"""
Test the get_state call never returns None
"""
@ -126,7 +127,7 @@ def test_poller_get_state_is_never_none(poller):
assert state is not None, 'get_state() return should not be None'
def test_send_message_works(settings):
def test_send_message_works(settings: Settings):
"""
Test the send_message_works really works
"""
@ -142,26 +143,38 @@ def test_send_message_works(settings):
server.worker.add_message_to_queues.assert_called_once_with(message)
def test_websocket_send_message_works(settings):
"""
Test the send_message_works really works
"""
def test_websocket_send_message_works(registry: Registry, settings: Settings):
"""Test the send_message_works really works"""
# GIVEN: A mocked WebSocketWorker and a message
server = WebSocketServer()
server.worker = MagicMock()
message = WebSocketMessage(plugin="core", key="test", value="test")
# WHEN: send_message is called
websocket_send_message(message)
result = websocket_send_message(message)
# THEN: Worker add_message_to_queues should be called
assert result is True
server.worker.add_message_to_queues.assert_called_once_with(message)
def test_websocket_send_message_fail(registry: Registry, settings: Settings):
"""Test the send_message_works returns False when there is no WebSocker server"""
# GIVEN: A message
message = WebSocketMessage(plugin="core", key="test", value="test")
# WHEN: send_message is called
result = websocket_send_message(message)
# THEN: The return value should be false
assert result is False
@patch('openlp.core.api.websockets.serve')
@patch('openlp.core.api.websockets.asyncio')
@patch('openlp.core.api.websockets.log')
def test_websocket_worker_start(mocked_log, mocked_asyncio, mocked_serve, worker, settings):
def test_websocket_worker_start(mocked_log: MagicMock, mocked_asyncio: MagicMock, mocked_serve: MagicMock,
worker: WebSocketWorker, settings: Settings):
"""
Test the start function of the worker
"""
@ -183,7 +196,8 @@ def test_websocket_worker_start(mocked_log, mocked_asyncio, mocked_serve, worker
@patch('openlp.core.api.websockets.serve')
@patch('openlp.core.api.websockets.asyncio')
@patch('openlp.core.api.websockets.log')
def test_websocket_worker_start_fail(mocked_log, mocked_asyncio, mocked_serve, worker, settings):
def test_websocket_worker_start_fail(mocked_log: MagicMock, mocked_asyncio: MagicMock, mocked_serve: MagicMock,
worker: WebSocketWorker, settings: Settings):
"""
Test the start function of the worker handles a error nicely
"""
@ -192,8 +206,10 @@ def test_websocket_worker_start_fail(mocked_log, mocked_asyncio, mocked_serve, w
event_loop = MagicMock()
mocked_asyncio.new_event_loop.return_value = event_loop
event_loop.run_until_complete.side_effect = Exception()
# WHEN: The start function is called
worker.start()
# THEN: An exception is logged but is handled and the event_loop is closed
mocked_serve.assert_called_once()
event_loop.run_until_complete.assert_called_once_with('server_thing')
@ -202,7 +218,30 @@ def test_websocket_worker_start_fail(mocked_log, mocked_asyncio, mocked_serve, w
event_loop.close.assert_called_once_with()
def test_websocket_server_bootstrap_post_set_up(settings):
@patch('openlp.core.api.websockets.serve')
@patch('openlp.core.api.websockets.asyncio')
@patch('openlp.core.api.websockets.log')
def test_websocket_worker_start_exception(mocked_log: MagicMock, mocked_asyncio: MagicMock, mocked_serve: MagicMock,
worker: WebSocketWorker, settings: Settings):
"""
Test the start function of the worker handles a error nicely
"""
# GIVEN: A mocked serve function and event loop. run_until_complete returns a error
mocked_serve.return_value = None
mocked_serve.side_effect = Exception('Test')
# WHEN: The start function is called
worker.start()
# THEN: An exception is logged but is handled and the event_loop is closed
assert worker.server is None
assert not worker.state_queues
assert not worker.message_queues
mocked_log.exception.assert_called_with('Failed to start WebSocket server')
mocked_log.error.assert_called_once_with('Unable to start WebSocket server 0.0.0.0:4317, giving up')
def test_websocket_server_bootstrap_post_set_up(settings: Settings):
"""
Test that the bootstrap_post_set_up() method calls the start method
"""
@ -220,7 +259,7 @@ def test_websocket_server_bootstrap_post_set_up(settings):
@patch('openlp.core.api.websockets.WebSocketWorker')
@patch('openlp.core.api.websockets.run_thread')
def test_websocket_server_start(mocked_run_thread, MockWebSocketWorker, registry):
def test_websocket_server_start(mocked_run_thread: MagicMock, MockWebSocketWorker: MagicMock, registry: Registry):
"""
Test the starting of the WebSockets Server with the disabled flag set off
"""
@ -238,7 +277,7 @@ def test_websocket_server_start(mocked_run_thread, MockWebSocketWorker, registry
@patch('openlp.core.api.websockets.WebSocketWorker')
@patch('openlp.core.api.websockets.run_thread')
def test_websocket_server_start_not_required(mocked_run_thread, MockWebSocketWorker, registry):
def test_websocket_server_start_not_required(mocked_run_thread, MockWebSocketWorker, registry: Registry):
"""
Test the starting of the WebSockets Server with the disabled flag set on
"""
@ -256,7 +295,7 @@ def test_websocket_server_start_not_required(mocked_run_thread, MockWebSocketWor
@patch('openlp.core.api.websockets.poller')
@patch('openlp.core.api.websockets.run_thread')
def test_websocket_server_connects_to_poller(mock_run_thread, mock_poller, settings):
def test_websocket_server_connects_to_poller(mock_run_thread: MagicMock, mock_poller: MagicMock, settings: Settings):
"""
Test if the websocket_server connects to WebSocketPoller
"""
@ -277,7 +316,8 @@ def test_websocket_server_connects_to_poller(mock_run_thread, mock_poller, setti
@patch('openlp.core.api.websockets.poller')
@patch('openlp.core.api.websockets.WebSocketWorker.add_state_to_queues')
@patch('openlp.core.api.websockets.run_thread')
def test_websocket_worker_register_connections(mock_run_thread, mock_add_state_to_queues, mock_poller, settings):
def test_websocket_worker_register_connections(mock_run_thread: MagicMock, mock_add_state_to_queues: MagicMock,
mock_poller: MagicMock, settings: Settings):
"""
Test if the websocket_server can receive poller signals
"""
@ -297,7 +337,7 @@ def test_websocket_worker_register_connections(mock_run_thread, mock_add_state_t
@patch('openlp.core.api.websockets.poller')
@patch('openlp.core.api.websockets.log')
def test_websocket_server_try_poller_hook_signals(mocked_log, mock_poller, settings):
def test_websocket_server_try_poller_hook_signals(mocked_log: MagicMock, mock_poller: MagicMock, settings: Settings):
"""
Test if the websocket_server invokes poller.hook_signals
"""
@ -315,7 +355,7 @@ def test_websocket_server_try_poller_hook_signals(mocked_log, mock_poller, setti
@patch('openlp.core.api.websockets.poller')
def test_websocket_server_close(mock_poller, settings):
def test_websocket_server_close(mock_poller: MagicMock, settings: Settings):
"""
Test that the websocket_server close method works correctly
"""
@ -338,7 +378,7 @@ def test_websocket_server_close(mock_poller, settings):
@patch('openlp.core.api.websockets.poller')
def test_websocket_server_close_when_disabled(mock_poller, registry, settings):
def test_websocket_server_close_when_disabled(mock_poller: MagicMock, registry: Registry, settings: Settings):
"""
Test if the websocket_server close method correctly skips teardown when disabled
"""
@ -355,3 +395,78 @@ def test_websocket_server_close_when_disabled(mock_poller, registry, settings):
# THEN: poller_changed should be connected with WebSocketServer and correct handler
assert mock_poller.poller_changed.disconnect.call_count == 0
assert mock_poller.unhook_signals.call_count == 0
def test_add_state_to_queues(worker: WebSocketWorker, settings: Settings):
"""Test that adding the state adds the state to each item in the queue"""
# GIVEN: A WebSocketWorker and some mocked methods
worker.event_loop = MagicMock(**{'is_running.return_value': True})
mocked_queue_1 = MagicMock(put_nowait='put_nowait1')
mocked_queue_2 = MagicMock(put_nowait='put_nowait2')
worker.state_queues = MagicMock(**{'copy.return_value': [mocked_queue_1, mocked_queue_2]})
# WHEN: add_state_to_queues is called
worker.add_state_to_queues({'results': {'service': 'service-id'}})
# THEN: The correct calls should have been made
assert worker.event_loop.call_soon_threadsafe.call_args_list == [
call('put_nowait1', {'results': {'service': 'service-id'}}),
call('put_nowait2', {'results': {'service': 'service-id'}})
]
def test_add_state_to_queues_no_loop(worker: WebSocketWorker, settings: Settings):
"""Test that adding the state when there's no event loop just exits early"""
# GIVEN: A WebSocketWorker and some mocked methods
worker.event_loop = MagicMock(**{'is_running.return_value': False})
# WHEN: add_state_to_queues is called
worker.add_state_to_queues({'results': {'service': 'service-id'}})
# THEN: Worker add_message_to_queues should be called
worker.event_loop.call_soon_threadsafe.assert_not_called()
def test_add_message_to_queues(worker: WebSocketWorker, settings: Settings):
"""Test that adding the message adds the message to each item in the queue"""
# GIVEN: A WebSocketWorker and some mocked methods
worker.event_loop = MagicMock(**{'is_running.return_value': True})
mocked_queue_1 = MagicMock(put_nowait='put_nowait1')
mocked_queue_2 = MagicMock(put_nowait='put_nowait2')
worker.message_queues = MagicMock(**{'copy.return_value': [mocked_queue_1, mocked_queue_2]})
message = WebSocketMessage(plugin="core", key="test", value="test")
# WHEN: add_state_to_queues is called
worker.add_message_to_queues(message)
# THEN: The correct calls should have been made
assert worker.event_loop.call_soon_threadsafe.call_args_list == [
call('put_nowait1', {'plugin': 'core', 'key': 'test', 'value': 'test'}),
call('put_nowait2', {'plugin': 'core', 'key': 'test', 'value': 'test'}),
]
def test_add_message_to_queues_no_loop(worker: WebSocketWorker, settings: Settings):
"""Test that adding the state when there's no event loop just exits early"""
# GIVEN: A WebSocketWorker and some mocked methods
worker.event_loop = MagicMock(**{'is_running.return_value': False})
message = WebSocketMessage(plugin="core", key="test", value="test")
# WHEN: add_state_to_queues is called
worker.add_message_to_queues(message)
# THEN: Worker add_message_to_queues should be called
worker.event_loop.call_soon_threadsafe.assert_not_called()
def test_worker_stop(worker: WebSocketWorker, settings: Settings):
"""Test that the worker stops"""
# GIVEN: A WebSocketWorker
worker.event_loop = MagicMock()
worker.event_loop.call_soon_threadsafe.side_effect = Exception('Test')
# WHEN: stop is called
worker.stop()
# THEN: No exception and the method should have been called
worker.event_loop.call_soon_threadsafe.assert_called_once()

View File

@ -20,6 +20,7 @@
##########################################################################
import pytest
from pathlib import Path
from typing import Generator
from unittest.mock import MagicMock, patch
from openlp.core.common.registry import Registry
@ -27,14 +28,14 @@ from openlp.core.server import Server
@pytest.fixture
def server(registry):
with patch('PyQt5.QtNetwork.QLocalSocket'):
def server(registry: Registry) -> Generator:
with patch('openlp.core.server.QtNetwork.QLocalSocket'):
server = Server()
yield server
server.close_server()
def test_is_another_instance_running(server):
def test_is_another_instance_running(server: Server):
"""
Run a test as if this was the first time and no instance is running
"""
@ -49,7 +50,7 @@ def test_is_another_instance_running(server):
assert isinstance(value, MagicMock)
def test_is_another_instance_running_true(server):
def test_is_another_instance_running_true(server: Server):
"""
Run a test as if there is another instance running
"""
@ -65,9 +66,9 @@ def test_is_another_instance_running_true(server):
assert value is True
def test_on_read_ready(server):
def test_on_ready_read(server: Server):
"""
Test the on_read_ready method calls the service_manager
Test the _on_ready_read method calls the service_manager
"""
# GIVEN: A server with a service manager
server.in_stream = MagicMock()
@ -84,29 +85,150 @@ def test_on_read_ready(server):
service_manager.load_service.assert_called_once_with(Path(file_name))
@patch("PyQt5.QtCore.QTextStream")
def test_post_to_server(mocked_stream, server):
@patch('openlp.core.server.QtCore.QTimer')
def test_on_ready_read_no_service_manager(MockQTimer: MagicMock, server: Server):
"""
Check that the _on_ready_read method calls a timer when the service_manager is not yet available
"""
# GIVEN: A server with a service manager
server.in_stream = MagicMock()
# WHEN: a file is added to the socket and the method called
file_name = '\\home\\superfly\\'
server.in_stream.readLine.return_value = file_name
server._on_ready_read()
# THEN: the service will be loaded
assert server._ms_waited == 500
MockQTimer.singleShot.assert_called_once_with(500, server._on_ready_read)
def test_on_ready_read_giving_up(server: Server):
"""
Check that the _on_ready_read gives up when it has waited for 2 minutes and the service manager is not available
"""
# GIVEN: A server that has waited too long
server.file_name = '/path/to/service.osz'
server._ms_waited = 120500
# WHEN: _on_ready_read has been called from the timer
with patch.object(server, 'log_error') as mocked_log_error:
server._on_ready_read()
# THEN: the service will be loaded
mocked_log_error.assert_called_once_with('OpenLP is taking too long to start up, abandoning file load')
@patch('openlp.core.server.QtCore.QTextStream')
def test_post_to_server(MockStream: MagicMock, server: Server):
"""
A Basic test with a post to the service
:return:
"""
# GIVEN: A server
# WHEN: I post to a server
server.post_to_server(['l', 'a', 'm', 'a', 's'])
server.post_to_server(['l', 'l', 'a', 'm', 'a', 's'])
# THEN: the file should be passed out to the socket
server.out_socket.write.assert_called_once_with(b'lamas')
server.out_socket.write.assert_called_once_with(b'llamas')
@patch("PyQt5.QtCore.QTextStream")
def test_post_to_server_openlp(mocked_stream, server):
@patch('openlp.core.server.QtCore.QTextStream')
def test_post_to_server_openlp(MockStream: MagicMock, server: Server):
"""
A Basic test with a post to the service with OpenLP
:return:
"""
# GIVEN: A server
# WHEN: I post to a server
server.post_to_server(['l', 'a', 'm', 'a', 's', 'OpenLP'])
server.post_to_server(['l', 'l', 'a', 'm', 'a', 's', 'OpenLP'])
# THEN: the file should be passed out to the socket
server.out_socket.write.assert_called_once_with(b'lamas')
server.out_socket.write.assert_called_once_with(b'llamas')
@patch('openlp.core.server.QtCore.QTextStream')
def test_post_to_server_openlp_exception(MockStream: MagicMock, server: Server):
"""Test that we raise an exception when there are no bytes written"""
# GIVEN: A server and a mocked stream
server.out_socket.waitForBytesWritten.return_value = False
server.out_socket.errorString.return_value = 'Error writing to socket'
# WHEN: post_to_server is called
# THEN: An exception is raised
with pytest.raises(Exception) as e:
server.post_to_server(['filename'])
assert 'Error writing to socket' in str(e)
@patch('openlp.core.server.QtNetwork.QLocalServer')
def test_start_server(MockLocalServer: MagicMock, server: Server):
"""Test the start server method works correctly"""
# GIVEN: A server
server.out_stream = MagicMock()
server.out_socket = MagicMock()
server.in_stream = MagicMock()
server.in_socket = MagicMock()
mocked_server = MagicMock()
MockLocalServer.return_value = mocked_server
# WHEN: start_server is called
result = server.start_server()
# THEN: The server should have been started
assert result is True
assert server.out_socket is None
assert server.out_stream is None
assert server.in_socket is None
assert server.in_stream is None
assert server.server is mocked_server
mocked_server.listen.assert_called_once_with(server.id)
mocked_server.newConnection.connect.assert_called_once_with(server._on_new_connection)
@patch('openlp.core.server.QtCore.QTextStream')
def test_on_new_connection(MockTextStream: MagicMock, server: Server):
"""Test that the _on_new_connection slot works correctly"""
# GIVEN: A server with some mocked properties
mocked_stream = MagicMock()
MockTextStream.return_value = mocked_stream
server.in_socket = MagicMock()
mocked_next_socket = MagicMock()
server.server = MagicMock(**{'nextPendingConnection.return_value': mocked_next_socket})
# WHEN: _on_new_connection is called
server._on_new_connection()
# THEN: The correct methods and attributes should have been called/set up
assert server.in_socket is mocked_next_socket
assert server.in_stream is mocked_stream
MockTextStream.assert_called_once_with(mocked_next_socket)
mocked_stream.setCodec.assert_called_once_with('UTF-8')
mocked_next_socket.readyRead.connect.assert_called_once_with(server._on_ready_read)
@patch('openlp.core.server.QtCore.QTextStream')
def test_on_new_connection_no_socket(MockTextStream: MagicMock, server: Server):
"""Test that the _on_new_connection slot works correctly"""
# GIVEN: A server with some mocked properties
server.in_socket = MagicMock()
server.server = MagicMock(**{'nextPendingConnection.return_value': None})
# WHEN: _on_new_connection is called
server._on_new_connection()
# THEN: The correct methods and attributes should have been called/set up
assert server.in_socket is None
MockTextStream.assert_not_called()
def test_close_server(server: Server):
"""Test that the server is closed"""
# GIVEN: A server
server.server = MagicMock()
# WHEN: The close_server() method is called
server.close_server()
# THEN: The server is closed
server.server.close.assert_called_once_with()