1
0

test_mqtt.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. # systemctl-mqtt - MQTT client triggering & reporting shutdown on systemd-based systems
  2. #
  3. # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. import datetime
  18. import logging
  19. import ssl
  20. import unittest.mock
  21. import aiomqtt
  22. import jeepney.fds
  23. import jeepney.low_level
  24. import pytest
  25. import systemctl_mqtt
  26. # pylint: disable=protected-access,too-many-positional-arguments
  27. @pytest.mark.asyncio
  28. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  29. @pytest.mark.parametrize("mqtt_port", [1883])
  30. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
  31. @pytest.mark.parametrize("homeassistant_discovery_prefix", ["homeassistant"])
  32. @pytest.mark.parametrize("homeassistant_discovery_object_id", ["host", "node"])
  33. async def test__run(
  34. caplog,
  35. mqtt_host,
  36. mqtt_port,
  37. mqtt_topic_prefix,
  38. homeassistant_discovery_prefix,
  39. homeassistant_discovery_object_id,
  40. ):
  41. # pylint: disable=too-many-locals,too-many-arguments
  42. caplog.set_level(logging.DEBUG)
  43. login_manager_mock = unittest.mock.MagicMock()
  44. with unittest.mock.patch(
  45. "aiomqtt.Client", autospec=False
  46. ) as mqtt_client_class_mock, unittest.mock.patch(
  47. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  48. return_value=login_manager_mock,
  49. ), unittest.mock.patch(
  50. "systemctl_mqtt._dbus_signal_loop"
  51. ) as dbus_signal_loop_mock:
  52. login_manager_mock.Inhibit.return_value = (jeepney.fds.FileDescriptor(-1),)
  53. login_manager_mock.Get.return_value = (("b", False),)
  54. await systemctl_mqtt._run(
  55. mqtt_host=mqtt_host,
  56. mqtt_port=mqtt_port,
  57. mqtt_username=None,
  58. mqtt_password=None,
  59. mqtt_topic_prefix=mqtt_topic_prefix,
  60. homeassistant_discovery_prefix=homeassistant_discovery_prefix,
  61. homeassistant_discovery_object_id=homeassistant_discovery_object_id,
  62. poweroff_delay=datetime.timedelta(),
  63. monitored_system_unit_names=[],
  64. controlled_system_unit_names=[],
  65. )
  66. assert caplog.records[0].levelno == logging.INFO
  67. assert caplog.records[0].message == (
  68. f"connecting to MQTT broker {mqtt_host}:{mqtt_port} (TLS enabled)"
  69. )
  70. mqtt_client_class_mock.assert_called_once()
  71. _, mqtt_client_init_kwargs = mqtt_client_class_mock.call_args
  72. assert mqtt_client_init_kwargs.pop("hostname") == mqtt_host
  73. assert mqtt_client_init_kwargs.pop("port") == mqtt_port
  74. assert isinstance(mqtt_client_init_kwargs.pop("tls_context"), ssl.SSLContext)
  75. assert mqtt_client_init_kwargs.pop("username") is None
  76. assert mqtt_client_init_kwargs.pop("password") is None
  77. assert mqtt_client_init_kwargs.pop("will") == aiomqtt.Will(
  78. topic=mqtt_topic_prefix + "/status",
  79. payload="offline",
  80. qos=0,
  81. retain=True,
  82. properties=None,
  83. )
  84. assert not mqtt_client_init_kwargs
  85. login_manager_mock.Inhibit.assert_called_once_with(
  86. what="shutdown",
  87. who="systemctl-mqtt",
  88. why="Report shutdown via MQTT",
  89. mode="delay",
  90. )
  91. login_manager_mock.Get.assert_called_once_with("PreparingForShutdown")
  92. async with mqtt_client_class_mock() as mqtt_client_mock:
  93. pass
  94. assert mqtt_client_mock.publish.call_count == 4
  95. assert (
  96. mqtt_client_mock.publish.call_args_list[0][1]["topic"]
  97. == f"{homeassistant_discovery_prefix}/device/{homeassistant_discovery_object_id}/config"
  98. )
  99. assert mqtt_client_mock.publish.call_args_list[1] == unittest.mock.call(
  100. topic=mqtt_topic_prefix + "/preparing-for-shutdown",
  101. payload="false",
  102. retain=False,
  103. )
  104. assert mqtt_client_mock.publish.call_args_list[2][1] == {
  105. "topic": mqtt_topic_prefix + "/status",
  106. "payload": "online",
  107. "retain": True,
  108. }
  109. assert mqtt_client_mock.publish.call_args_list[3][1] == {
  110. "topic": mqtt_topic_prefix + "/status",
  111. "payload": "offline",
  112. "retain": True,
  113. }
  114. assert sorted(mqtt_client_mock.subscribe.call_args_list) == [
  115. unittest.mock.call("homeassistant/status"),
  116. unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
  117. unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
  118. unittest.mock.call(mqtt_topic_prefix + "/suspend"),
  119. ]
  120. assert caplog.records[1].levelno == logging.DEBUG
  121. assert (
  122. caplog.records[1].message == f"connected to MQTT broker {mqtt_host}:{mqtt_port}"
  123. )
  124. assert caplog.records[2].levelno == logging.DEBUG
  125. assert caplog.records[2].message == "acquired shutdown inhibitor lock"
  126. assert caplog.records[3].levelno == logging.DEBUG
  127. assert (
  128. caplog.records[3].message
  129. == "publishing home assistant config on "
  130. + homeassistant_discovery_prefix
  131. + "/device/"
  132. + homeassistant_discovery_object_id
  133. + "/config"
  134. )
  135. assert caplog.records[4].levelno == logging.INFO
  136. assert (
  137. caplog.records[4].message
  138. == f"publishing 'false' on {mqtt_topic_prefix}/preparing-for-shutdown"
  139. )
  140. assert all(r.levelno == logging.INFO for r in caplog.records[5:])
  141. expected_subscription_messages = {
  142. f"subscribing to {mqtt_topic_prefix}/{s}"
  143. for s in ("poweroff", "lock-all-sessions", "suspend")
  144. } | {"subscribing to homeassistant/status"}
  145. assert {r.message for r in caplog.records[5:]} == expected_subscription_messages
  146. dbus_signal_loop_mock.assert_awaited_once()
  147. @pytest.mark.asyncio
  148. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  149. @pytest.mark.parametrize("mqtt_port", [1833])
  150. @pytest.mark.parametrize("mqtt_disable_tls", [True, False])
  151. async def test__run_tls(caplog, mqtt_host, mqtt_port, mqtt_disable_tls):
  152. caplog.set_level(logging.INFO)
  153. with unittest.mock.patch(
  154. "aiomqtt.Client"
  155. ) as mqtt_client_class_mock, unittest.mock.patch(
  156. "systemctl_mqtt._dbus_signal_loop"
  157. ) as dbus_signal_loop_mock:
  158. await systemctl_mqtt._run(
  159. mqtt_host=mqtt_host,
  160. mqtt_port=mqtt_port,
  161. mqtt_disable_tls=mqtt_disable_tls,
  162. mqtt_username=None,
  163. mqtt_password=None,
  164. mqtt_topic_prefix="systemctl/hosts",
  165. homeassistant_discovery_prefix="homeassistant",
  166. homeassistant_discovery_object_id="host",
  167. poweroff_delay=datetime.timedelta(),
  168. monitored_system_unit_names=[],
  169. controlled_system_unit_names=[],
  170. )
  171. mqtt_client_class_mock.assert_called_once()
  172. _, mqtt_client_init_kwargs = mqtt_client_class_mock.call_args
  173. assert mqtt_client_init_kwargs.pop("hostname") == mqtt_host
  174. assert mqtt_client_init_kwargs.pop("port") == mqtt_port
  175. if mqtt_disable_tls:
  176. assert mqtt_client_init_kwargs.pop("tls_context") is None
  177. else:
  178. assert isinstance(mqtt_client_init_kwargs.pop("tls_context"), ssl.SSLContext)
  179. assert set(mqtt_client_init_kwargs.keys()) == {"username", "password", "will"}
  180. assert caplog.records[0].levelno == logging.INFO
  181. assert caplog.records[0].message == (
  182. f"connecting to MQTT broker {mqtt_host}:{mqtt_port}"
  183. f" (TLS {'disabled' if mqtt_disable_tls else 'enabled'})"
  184. )
  185. dbus_signal_loop_mock.assert_awaited_once()
  186. @pytest.mark.asyncio
  187. async def test__run_tls_default():
  188. with unittest.mock.patch(
  189. "aiomqtt.Client"
  190. ) as mqtt_client_class_mock, unittest.mock.patch(
  191. "systemctl_mqtt._dbus_signal_loop"
  192. ) as dbus_signal_loop_mock:
  193. await systemctl_mqtt._run(
  194. mqtt_host="mqtt-broker.local",
  195. mqtt_port=1883,
  196. # mqtt_disable_tls default,
  197. mqtt_username=None,
  198. mqtt_password=None,
  199. mqtt_topic_prefix="systemctl/hosts",
  200. homeassistant_discovery_prefix="homeassistant",
  201. homeassistant_discovery_object_id="host",
  202. poweroff_delay=datetime.timedelta(),
  203. monitored_system_unit_names=[],
  204. controlled_system_unit_names=[],
  205. )
  206. mqtt_client_class_mock.assert_called_once()
  207. # enabled by default
  208. assert isinstance(
  209. mqtt_client_class_mock.call_args[1]["tls_context"], ssl.SSLContext
  210. )
  211. dbus_signal_loop_mock.assert_awaited_once()
  212. @pytest.mark.asyncio
  213. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  214. @pytest.mark.parametrize("mqtt_port", [1883])
  215. @pytest.mark.parametrize("mqtt_username", ["me"])
  216. @pytest.mark.parametrize("mqtt_password", [None, "secret"])
  217. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
  218. async def test__run_authentication(
  219. mqtt_host, mqtt_port, mqtt_username, mqtt_password, mqtt_topic_prefix
  220. ):
  221. with unittest.mock.patch(
  222. "aiomqtt.Client"
  223. ) as mqtt_client_class_mock, unittest.mock.patch(
  224. "systemctl_mqtt._dbus_signal_loop"
  225. ) as dbus_signal_loop_mock:
  226. await systemctl_mqtt._run(
  227. mqtt_host=mqtt_host,
  228. mqtt_port=mqtt_port,
  229. mqtt_username=mqtt_username,
  230. mqtt_password=mqtt_password,
  231. mqtt_topic_prefix=mqtt_topic_prefix,
  232. homeassistant_discovery_prefix="discovery-prefix",
  233. homeassistant_discovery_object_id="node-id",
  234. poweroff_delay=datetime.timedelta(),
  235. monitored_system_unit_names=[],
  236. controlled_system_unit_names=[],
  237. )
  238. mqtt_client_class_mock.assert_called_once()
  239. _, mqtt_client_init_kwargs = mqtt_client_class_mock.call_args
  240. assert mqtt_client_init_kwargs["username"] == mqtt_username
  241. if mqtt_password:
  242. assert mqtt_client_init_kwargs["password"] == mqtt_password
  243. else:
  244. assert mqtt_client_init_kwargs["password"] is None
  245. dbus_signal_loop_mock.assert_awaited_once()
  246. @pytest.mark.asyncio
  247. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  248. @pytest.mark.parametrize("mqtt_port", [1883])
  249. @pytest.mark.parametrize("mqtt_password", ["secret"])
  250. async def test__run_authentication_missing_username(
  251. mqtt_host: str, mqtt_port: int, mqtt_password: str
  252. ) -> None:
  253. with unittest.mock.patch("aiomqtt.Client"), unittest.mock.patch(
  254. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy"
  255. ), unittest.mock.patch("systemctl_mqtt._dbus_signal_loop") as dbus_signal_loop_mock:
  256. with pytest.raises(ValueError, match=r"^Missing MQTT username$"):
  257. await systemctl_mqtt._run(
  258. mqtt_host=mqtt_host,
  259. mqtt_port=mqtt_port,
  260. mqtt_username=None,
  261. mqtt_password=mqtt_password,
  262. mqtt_topic_prefix="prefix",
  263. homeassistant_discovery_prefix="discovery-prefix",
  264. homeassistant_discovery_object_id="node-id",
  265. poweroff_delay=datetime.timedelta(),
  266. monitored_system_unit_names=[],
  267. controlled_system_unit_names=[],
  268. )
  269. dbus_signal_loop_mock.assert_not_called()
  270. @pytest.mark.asyncio
  271. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
  272. async def test__run_sigint(mqtt_topic_prefix: str):
  273. login_manager_mock = unittest.mock.MagicMock()
  274. with unittest.mock.patch(
  275. "aiomqtt.Client", autospec=False
  276. ) as mqtt_client_class_mock, unittest.mock.patch(
  277. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  278. return_value=login_manager_mock,
  279. ), unittest.mock.patch(
  280. "asyncio.gather", side_effect=KeyboardInterrupt
  281. ):
  282. login_manager_mock.Inhibit.return_value = (jeepney.fds.FileDescriptor(-1),)
  283. login_manager_mock.Get.return_value = (("b", False),)
  284. with pytest.raises(KeyboardInterrupt):
  285. await systemctl_mqtt._run(
  286. mqtt_host="mqtt-broker.local",
  287. mqtt_port=1883,
  288. mqtt_username=None,
  289. mqtt_password=None,
  290. mqtt_topic_prefix=mqtt_topic_prefix,
  291. homeassistant_discovery_prefix="homeassistant",
  292. homeassistant_discovery_object_id="host",
  293. poweroff_delay=datetime.timedelta(),
  294. monitored_system_unit_names=[],
  295. controlled_system_unit_names=[],
  296. )
  297. async with mqtt_client_class_mock() as mqtt_client_mock:
  298. pass
  299. assert mqtt_client_mock.publish.call_count == 4
  300. assert mqtt_client_mock.publish.call_args_list[0][1]["topic"].endswith("/config")
  301. assert mqtt_client_mock.publish.call_args_list[1][1]["topic"].endswith(
  302. "/preparing-for-shutdown"
  303. )
  304. assert mqtt_client_mock.publish.call_args_list[2][1] == {
  305. "topic": mqtt_topic_prefix + "/status",
  306. "payload": "online",
  307. "retain": True,
  308. }
  309. assert mqtt_client_mock.publish.call_args_list[3][1] == {
  310. "topic": mqtt_topic_prefix + "/status",
  311. "payload": "offline",
  312. "retain": True,
  313. }
  314. @pytest.mark.asyncio
  315. @pytest.mark.filterwarnings("ignore:coroutine '_dbus_signal_loop' was never awaited")
  316. @pytest.mark.filterwarnings("ignore:coroutine '_mqtt_message_loop' was never awaited")
  317. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
  318. async def test__mqtt_message_loop_trigger_poweroff(
  319. caplog: pytest.LogCaptureFixture, mqtt_topic_prefix: str
  320. ) -> None:
  321. state = systemctl_mqtt._State(
  322. mqtt_topic_prefix=mqtt_topic_prefix,
  323. homeassistant_discovery_prefix="homeassistant",
  324. homeassistant_discovery_object_id="whatever",
  325. poweroff_delay=datetime.timedelta(seconds=21),
  326. monitored_system_unit_names=[],
  327. controlled_system_unit_names=[],
  328. )
  329. mqtt_client_mock = unittest.mock.AsyncMock()
  330. mqtt_client_mock.messages.__aiter__.return_value = [
  331. aiomqtt.Message(
  332. topic=mqtt_topic_prefix + "/poweroff",
  333. payload=b"some-payload",
  334. qos=0,
  335. retain=False,
  336. mid=42 // 2,
  337. properties=None,
  338. )
  339. ]
  340. with unittest.mock.patch(
  341. "systemctl_mqtt._dbus.login_manager.schedule_shutdown"
  342. ) as schedule_shutdown_mock, caplog.at_level(logging.DEBUG):
  343. await systemctl_mqtt._mqtt_message_loop(
  344. state=state, mqtt_client=mqtt_client_mock
  345. )
  346. assert sorted(mqtt_client_mock.subscribe.await_args_list) == [
  347. unittest.mock.call("homeassistant/status"),
  348. unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
  349. unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
  350. unittest.mock.call(mqtt_topic_prefix + "/suspend"),
  351. ]
  352. schedule_shutdown_mock.assert_called_once_with(
  353. action="poweroff", delay=datetime.timedelta(seconds=21)
  354. )
  355. assert [
  356. t for t in caplog.record_tuples[2:] if not t[2].startswith("subscribing to ")
  357. ] == [
  358. (
  359. "systemctl_mqtt",
  360. logging.DEBUG,
  361. f"received message on topic '{mqtt_topic_prefix}/poweroff': b'some-payload'",
  362. ),
  363. ]
  364. @pytest.mark.asyncio
  365. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
  366. async def test__mqtt_message_loop_retained(
  367. caplog: pytest.LogCaptureFixture, mqtt_topic_prefix: str
  368. ) -> None:
  369. state = systemctl_mqtt._State(
  370. mqtt_topic_prefix=mqtt_topic_prefix,
  371. homeassistant_discovery_prefix="homeassistant",
  372. homeassistant_discovery_object_id="whatever",
  373. poweroff_delay=datetime.timedelta(seconds=21),
  374. monitored_system_unit_names=[],
  375. controlled_system_unit_names=[],
  376. )
  377. mqtt_client_mock = unittest.mock.AsyncMock()
  378. mqtt_client_mock.messages.__aiter__.return_value = [
  379. aiomqtt.Message(
  380. topic=mqtt_topic_prefix + "/poweroff",
  381. payload=b"some-payload",
  382. qos=0,
  383. retain=True,
  384. mid=42 // 2,
  385. properties=None,
  386. )
  387. ]
  388. with unittest.mock.patch(
  389. "systemctl_mqtt._dbus.login_manager.schedule_shutdown"
  390. ) as schedule_shutdown_mock, caplog.at_level(logging.DEBUG):
  391. await systemctl_mqtt._mqtt_message_loop(
  392. state=state, mqtt_client=mqtt_client_mock
  393. )
  394. schedule_shutdown_mock.assert_not_called()
  395. assert [
  396. t for t in caplog.record_tuples[2:] if not t[2].startswith("subscribing to ")
  397. ] == [
  398. (
  399. "systemctl_mqtt",
  400. logging.INFO,
  401. "ignoring retained message on topic 'systemctl/host/poweroff'",
  402. ),
  403. ]
  404. @pytest.mark.asyncio
  405. async def test__mqtt_message_loop_homeassistant_status_online(
  406. caplog: pytest.LogCaptureFixture,
  407. ) -> None:
  408. mqtt_topic_prefix = "systemctl/host"
  409. state = systemctl_mqtt._State(
  410. mqtt_topic_prefix=mqtt_topic_prefix,
  411. homeassistant_discovery_prefix="homeassistant",
  412. homeassistant_discovery_object_id="whatever",
  413. poweroff_delay=datetime.timedelta(seconds=21),
  414. monitored_system_unit_names=[],
  415. controlled_system_unit_names=[],
  416. )
  417. mqtt_client_mock = unittest.mock.AsyncMock()
  418. mqtt_client_mock.messages.__aiter__.return_value = [
  419. aiomqtt.Message(
  420. topic="homeassistant/status",
  421. payload=b"online",
  422. qos=0,
  423. retain=False,
  424. mid=1,
  425. properties=None,
  426. )
  427. ]
  428. with unittest.mock.patch.object(
  429. state, "publish_homeassistant_device_config"
  430. ) as publish_config_mock, caplog.at_level(logging.INFO):
  431. await systemctl_mqtt._mqtt_message_loop(
  432. state=state, mqtt_client=mqtt_client_mock
  433. )
  434. publish_config_mock.assert_awaited_once_with(mqtt_client=mqtt_client_mock)
  435. assert (
  436. unittest.mock.call("homeassistant/status")
  437. in mqtt_client_mock.subscribe.await_args_list
  438. )
  439. @pytest.mark.asyncio
  440. async def test__mqtt_message_loop_homeassistant_status_offline(caplog) -> None:
  441. mqtt_topic_prefix = "systemctl/host"
  442. state = systemctl_mqtt._State(
  443. mqtt_topic_prefix=mqtt_topic_prefix,
  444. homeassistant_discovery_prefix="homeassistant",
  445. homeassistant_discovery_object_id="whatever",
  446. poweroff_delay=datetime.timedelta(seconds=21),
  447. monitored_system_unit_names=[],
  448. controlled_system_unit_names=[],
  449. )
  450. mqtt_client_mock = unittest.mock.AsyncMock()
  451. mqtt_client_mock.messages.__aiter__.return_value = [
  452. aiomqtt.Message(
  453. topic="homeassistant/status",
  454. payload=b"offline",
  455. qos=0,
  456. retain=False,
  457. mid=2,
  458. properties=None,
  459. )
  460. ]
  461. with unittest.mock.patch.object(
  462. state, "publish_homeassistant_device_config"
  463. ) as publish_config_mock, caplog.at_level(logging.DEBUG):
  464. await systemctl_mqtt._mqtt_message_loop(
  465. state=state, mqtt_client=mqtt_client_mock
  466. )
  467. publish_config_mock.assert_not_called()
  468. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "systemd/raspberrypi"])
  469. @pytest.mark.parametrize("unit_name", ["foo.service", "bar.service"])
  470. def test_state_get_system_unit_active_state_mqtt_topic(
  471. mqtt_topic_prefix: str, unit_name: str
  472. ) -> None:
  473. state = systemctl_mqtt._State(
  474. mqtt_topic_prefix=mqtt_topic_prefix,
  475. homeassistant_discovery_prefix="homeassistant",
  476. homeassistant_discovery_object_id="whatever",
  477. poweroff_delay=datetime.timedelta(seconds=21),
  478. monitored_system_unit_names=[],
  479. controlled_system_unit_names=[],
  480. )
  481. assert (
  482. state.get_system_unit_active_state_mqtt_topic(unit_name=unit_name)
  483. == f"{mqtt_topic_prefix}/unit/system/{unit_name}/active-state"
  484. )
  485. @pytest.mark.asyncio
  486. @pytest.mark.filterwarnings("ignore:coroutine '_dbus_signal_loop' was never awaited")
  487. @pytest.mark.filterwarnings("ignore:coroutine '_mqtt_message_loop' was never awaited")
  488. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
  489. @pytest.mark.parametrize("unit_name", ["foo.service", "bar.service"])
  490. @pytest.mark.parametrize("action", ["restart", "start", "stop", "isolate"])
  491. async def test__mqtt_message_loop_triggers_unit_action(
  492. caplog: pytest.LogCaptureFixture,
  493. mqtt_topic_prefix: str,
  494. unit_name: str,
  495. action: str,
  496. ) -> None:
  497. state = systemctl_mqtt._State(
  498. mqtt_topic_prefix=mqtt_topic_prefix,
  499. homeassistant_discovery_prefix="homeassistant",
  500. homeassistant_discovery_object_id="whatever",
  501. poweroff_delay=datetime.timedelta(seconds=21),
  502. monitored_system_unit_names=[],
  503. controlled_system_unit_names=[unit_name],
  504. )
  505. mqtt_client_mock = unittest.mock.AsyncMock()
  506. topic = f"{mqtt_topic_prefix}/unit/system/{unit_name}/{action}"
  507. mqtt_client_mock.messages.__aiter__.return_value = [
  508. aiomqtt.Message(
  509. topic=topic,
  510. payload=b"some-payload",
  511. qos=0,
  512. retain=False,
  513. mid=42 // 2,
  514. properties=None,
  515. )
  516. ]
  517. with unittest.mock.patch(
  518. f"systemctl_mqtt._dbus.service_manager.{action}_unit"
  519. ) as trigger_service_mock, caplog.at_level(logging.DEBUG):
  520. await systemctl_mqtt._mqtt_message_loop(
  521. state=state, mqtt_client=mqtt_client_mock
  522. )
  523. # check subscription
  524. assert unittest.mock.call(topic) in mqtt_client_mock.subscribe.await_args_list
  525. # check correct action method called
  526. trigger_service_mock.assert_called_once_with(unit_name=unit_name)
  527. # check logs (skip "subscribing to ..." chatter)
  528. assert [
  529. t for t in caplog.record_tuples[2:] if not t[2].startswith("subscribing to ")
  530. ] == [
  531. (
  532. "systemctl_mqtt",
  533. logging.DEBUG,
  534. f"received message on topic '{topic}': b'some-payload'",
  535. ),
  536. ]