"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback. The routes hand the request to the display over the control socket (src/ipc) and get an acknowledgement. On any failure -- no socket (a stopped display, or one older than the socket), a timeout, a refusal, a bug in the client -- they write the file mailbox exactly as they did before the socket existed. These tests pin both paths, that exactly one of them is used, that the response says which, and that the request id is the same either way (the display deduplicates on it). The socket client is patched at the route's module attribute; the last class runs a real server on a temp socket (Linux/macOS only). """ import os import sys from pathlib import Path from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).parent.parent)) from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 from src.ipc import client as control_client # noqa: E402 from src.ipc import contract as c # noqa: E402 START_URL = "/api/v3/display/on-demand/start" STOP_URL = "/api/v3/display/on-demand/stop" MAILBOX = "display_on_demand_request" CLIENT = "web_interface.blueprints.api_v3.display.control_client" @pytest.fixture def service(api_v3_module): """A running display service; records systemctl calls and mailbox writes.""" api_v3_module.api_v3.plugin_catalog = None api_v3_module.api_v3.config_manager = None state = {"active": True} calls = [] def status(): return {"active": state["active"]} def systemctl(args): calls.append(("systemctl", args[-2])) if args[-2:] == ["start", "ledmatrix.service"]: state["active"] = True return {"returncode": 0, "stdout": "", "stderr": ""} cache = api_v3_module.api_v3.cache_manager cache.set.side_effect = lambda key, value, *a, **kw: calls.append(("cache", key)) with patch("web_interface.blueprints.api_v3._get_display_service_status", side_effect=status), \ patch("web_interface.blueprints.api_v3.display._get_display_service_status", side_effect=status), \ patch("web_interface.blueprints.api_v3._run_systemctl_command", side_effect=systemctl), \ patch("web_interface.blueprints.api_v3.display._stop_display_service"): yield {"state": state, "cache": cache, "calls": calls} def _mailbox_writes(cache): return [call.args[1] for call in cache.set.call_args_list if call.args and call.args[0] == MAILBOX] def _ack(request_id, *a, **kw): return {"accepted": True, "request_id": request_id, "queued": 1} class TestSocketPath: def test_start_goes_over_the_socket_and_skips_the_mailbox(self, api_v3_client, service): with patch(f"{CLIENT}.on_demand_start", side_effect=_ack) as start: resp = api_v3_client.post(START_URL, json={ "plugin_id": "weather", "mode": "weather_current", "duration": 60, "pinned": True}) assert resp.status_code == 200, resp.get_json() data = resp.get_json()["data"] assert data["transport"] == "socket" assert "socket_error" not in data assert _mailbox_writes(service["cache"]) == [] start.assert_called_once_with(data["request_id"], "weather", "weather_current", 60, True) def test_a_callers_request_id_is_passed_through(self, api_v3_client, service): with patch(f"{CLIENT}.on_demand_start", side_effect=_ack) as start: data = api_v3_client.post(START_URL, json={ "plugin_id": "weather", "request_id": "ha-123"}).get_json()["data"] assert data["request_id"] == "ha-123" assert start.call_args.args[0] == "ha-123" def test_stop_goes_over_the_socket(self, api_v3_client, service): with patch(f"{CLIENT}.on_demand_stop", side_effect=_ack) as stop: data = api_v3_client.post(STOP_URL, json={}).get_json()["data"] assert data["transport"] == "socket" stop.assert_called_once_with(data["request_id"]) assert _mailbox_writes(service["cache"]) == [] class TestMailboxFallback: @pytest.mark.parametrize("reason", [ "no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request", "busy", "unknown_command", "unsupported_version", "disabled", "unsupported", ]) def test_any_socket_failure_writes_the_mailbox_as_before( self, api_v3_client, service, reason): with patch(f"{CLIENT}.on_demand_start", side_effect=control_client.ControlError(reason, "x")): resp = api_v3_client.post(START_URL, json={ "plugin_id": "weather", "mode": "weather_current", "duration": 60, "pinned": True}) assert resp.status_code == 200 data = resp.get_json()["data"] assert data["transport"] == "mailbox" assert data["socket_error"] == reason [write] = _mailbox_writes(service["cache"]) assert write["request_id"] == data["request_id"] assert write["action"] == "start" assert (write["plugin_id"], write["mode"], write["duration"], write["pinned"]) == \ ("weather", "weather_current", 60, True) def test_a_client_bug_still_falls_back(self, api_v3_client, service): with patch(f"{CLIENT}.on_demand_start", side_effect=RuntimeError("boom")): resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) assert resp.status_code == 200 assert resp.get_json()["data"]["socket_error"] == "internal" assert len(_mailbox_writes(service["cache"])) == 1 def test_stop_falls_back(self, api_v3_client, service): with patch(f"{CLIENT}.on_demand_stop", side_effect=control_client.ControlError("timeout")): data = api_v3_client.post(STOP_URL, json={}).get_json()["data"] assert data["transport"] == "mailbox" [write] = _mailbox_writes(service["cache"]) assert write == {"request_id": data["request_id"], "action": "stop", "timestamp": write["timestamp"]} def test_a_stopped_display_gets_the_mailbox_before_it_is_started( self, api_v3_client, service): service["state"]["active"] = False with patch(f"{CLIENT}.on_demand_start", side_effect=control_client.ControlError("no_socket")): resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) assert resp.status_code == 200 assert service["calls"] == [("cache", MAILBOX), ("systemctl", "start")] def test_the_socket_is_off_in_the_test_suite(self, api_v3_client, service): # conftest's _hermetic_control_socket: a suite run on a device must # not drive the live display. assert os.environ[c.SOCKET_PATH_ENV] == "off" data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"] assert data["transport"] == "mailbox" assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows @pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only") class TestRealSocket: @pytest.fixture def live(self, monkeypatch): import shutil import tempfile from src.ipc.server import ControlServer d = tempfile.mkdtemp(prefix="lmipc-") path = os.path.join(d, "control.sock") server = ControlServer(path, status_provider=dict) assert server.start() monkeypatch.setenv(c.SOCKET_PATH_ENV, path) yield server server.close() shutil.rmtree(d, ignore_errors=True) def test_start_is_acked_and_queued(self, api_v3_client, service, live): data = api_v3_client.post(START_URL, json={ "plugin_id": "weather", "duration": "30"}).get_json()["data"] assert data["transport"] == "socket" [cmd] = live.drain() payload = cmd.as_on_demand_request() assert payload["request_id"] == data["request_id"] assert payload["plugin_id"] == "weather" and payload["duration"] == 30.0 assert _mailbox_writes(service["cache"]) == [] def test_stop_is_acked_and_queued(self, api_v3_client, service, live): data = api_v3_client.post(STOP_URL, json={}).get_json()["data"] assert data["transport"] == "socket" assert [x.request_id for x in live.drain()] == [data["request_id"]] def test_a_display_that_went_away_falls_back(self, api_v3_client, service, live): live.close() data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"] assert data["transport"] == "mailbox" and data["socket_error"] == "no_socket" assert len(_mailbox_writes(service["cache"])) == 1