diff --git a/bmspy/client.py b/bmspy/client.py index ff6679c..1a64c92 100644 --- a/bmspy/client.py +++ b/bmspy/client.py @@ -45,6 +45,10 @@ def handle_registration(socket_path: str, client_name: str, debug: int = 0) -> d return data +class DaemonError(RuntimeError): + """The daemon answered, but it refused the request.""" + + def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0) -> dict[str, Any]: """Send a JSON request to the daemon socket and return the decoded JSON response.""" response_data: dict[str, Any] = dict() @@ -111,6 +115,15 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0) if debug > 2: debugger("socket client: received {!r}".format(response_data)) + # For example, a daemon that is older than this client and does not know + # the command. + if isinstance(response_data, dict) and response_data.get("status") == "ERROR": + raise DaemonError( + "daemon refused {!r}: {}".format( + request_data.get("command"), response_data.get("message") + ) + ) + return response_data diff --git a/tests/test_client.py b/tests/test_client.py index e2e7b4f..a54c44f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -91,6 +91,28 @@ class TestReadData: call_args = mock_comms.call_args[0][1] assert call_args.get("ups") == "myups" + @pytest.mark.parametrize("call", [ + lambda client: client.list_devices("/fake/socket", "test"), + lambda client: client.read_data("/fake/socket", "test"), + ], ids=["list_devices", "read_data"]) + def test_error_reply_raises_the_daemon_message(self, call): + """An ERROR reply must not reach the code that expects device data.""" + from bmspy import client + reply = json.dumps({"status": "ERROR", "message": "unknown command: 'DEVICES'"}).encode() + sock = MagicMock() + sock.recv.side_effect = [struct.pack("!I", len(reply)), reply] + with patch("bmspy.client.socket.socket", return_value=sock): + with pytest.raises(client.DaemonError, match="unknown command: 'DEVICES'"): + call(client) + sock.close.assert_called() + + def test_registration_logs_an_error_reply(self, capsys): + from bmspy import client + with patch("bmspy.client.socket_comms", side_effect=client.DaemonError("daemon refused 'REGISTER': no")), \ + patch("bmspy.client.is_registered", False): + client.handle_registration("/fake/socket", "test") + assert "daemon refused 'REGISTER': no" in capsys.readouterr().out + def test_list_devices_returns_configured_names(self): from bmspy.client import list_devices with patch("bmspy.client.socket_comms", return_value={"devices": ["network", "nas"]}) as mock_comms: diff --git a/tests/test_server.py b/tests/test_server.py index f732ac5..7ddd762 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -507,6 +507,21 @@ class TestServerMain: finally: self._stop_server(sock_path, t) + def test_unknown_command_is_a_clear_error_in_the_client(self, tmp_path): + """The real client, against a server that does not know the command.""" + from bmspy import client + + sock_path = str(tmp_path / "server_refused.sock") + ready = threading.Event() + t = self._make_server_thread(sock_path, ready, threading.Event()) + t.start() + ready.wait(timeout=2) + try: + with pytest.raises(client.DaemonError, match="daemon refused 'NEWER': unknown command: 'NEWER'"): + client.socket_comms(sock_path, {"command": "NEWER", "client": "test"}) + finally: + self._stop_server(sock_path, t) + def test_devices_command_lists_configured_devices(self, tmp_path): """DEVICES names every configured device, through the real client.""" from bmspy import client