test_services.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. """Tests for zlock_slots service handlers."""
  2. import pytest
  3. import voluptuous as vol
  4. from unittest.mock import patch, AsyncMock
  5. from pytest_homeassistant_custom_component.common import MockConfigEntry
  6. from homeassistant.exceptions import ServiceValidationError
  7. from custom_components.zlock_slots.coordinator import ZLockCoordinator
  8. from custom_components.zlock_slots.services import register_services, unregister_services
  9. from custom_components.zlock_slots.const import (
  10. DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
  11. )
  12. from tests.conftest import LOCK_ENTITY_ID, FakeUserData, FakeNode
  13. _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
  14. LOCK_2 = "lock.back_door"
  15. @pytest.fixture
  16. async def service_env(hass, config_entry, mock_node):
  17. """Coordinator wired into hass.data with services registered and mock zwave_js services ready."""
  18. config_entry.add_to_hass(hass)
  19. with patch(_ZWAVE_HELPER, return_value=mock_node):
  20. coord = ZLockCoordinator(hass, config_entry)
  21. await coord.async_setup()
  22. hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coord
  23. set_lock_usercode = AsyncMock()
  24. clear_lock_usercode = AsyncMock()
  25. hass.services.async_register("zwave_js", "set_lock_usercode", set_lock_usercode)
  26. hass.services.async_register("zwave_js", "clear_lock_usercode", clear_lock_usercode)
  27. register_services(hass)
  28. yield coord, set_lock_usercode, clear_lock_usercode
  29. unregister_services(hass)
  30. coord.async_teardown()
  31. @pytest.fixture
  32. async def group_service_env(hass):
  33. """Two-lock group, each backed by its own fake node, services registered."""
  34. entry = MockConfigEntry(
  35. domain=DOMAIN,
  36. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
  37. options={
  38. CONF_GROUPS: [{
  39. CONF_GROUP_NAME: "All Locks",
  40. CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
  41. }]
  42. },
  43. entry_id="test_group_service_entry",
  44. )
  45. entry.add_to_hass(hass)
  46. nodes = {
  47. LOCK_ENTITY_ID: FakeNode(users=[FakeUserData(1, active=True)]),
  48. LOCK_2: FakeNode(users=[FakeUserData(2, active=True)]),
  49. }
  50. def _node_for(hass_, entity_id):
  51. return nodes[entity_id]
  52. with patch(_ZWAVE_HELPER, side_effect=_node_for):
  53. coord = ZLockCoordinator(hass, entry)
  54. await coord.async_setup()
  55. hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coord
  56. set_lock_usercode = AsyncMock()
  57. clear_lock_usercode = AsyncMock()
  58. hass.services.async_register("zwave_js", "set_lock_usercode", set_lock_usercode)
  59. hass.services.async_register("zwave_js", "clear_lock_usercode", clear_lock_usercode)
  60. register_services(hass)
  61. with patch(_ZWAVE_HELPER, side_effect=_node_for):
  62. yield coord, nodes, set_lock_usercode, clear_lock_usercode
  63. unregister_services(hass)
  64. coord.async_teardown()
  65. # ---------------------------------------------------------------------------
  66. # set_code
  67. # ---------------------------------------------------------------------------
  68. async def test_set_code_calls_zwave_service(hass, service_env, mock_node):
  69. coord, set_lock_usercode, _ = service_env
  70. with patch(_ZWAVE_HELPER, return_value=mock_node):
  71. await hass.services.async_call(
  72. DOMAIN, "set_code",
  73. {"lock_entity_id": LOCK_ENTITY_ID, "slot": 5, "code": "1234"},
  74. blocking=True,
  75. )
  76. set_lock_usercode.assert_awaited_once()
  77. service_data = set_lock_usercode.call_args[0][0].data
  78. assert service_data["code_slot"] == 5
  79. assert service_data["usercode"] == "1234"
  80. assert service_data["entity_id"] == LOCK_ENTITY_ID
  81. async def test_set_code_auto_assigns_lowest_free_slot(hass, service_env, mock_node):
  82. """Slots 1 and 2 occupied → auto-assign picks 3."""
  83. coord, set_lock_usercode, _ = service_env
  84. with patch(_ZWAVE_HELPER, return_value=mock_node):
  85. await hass.services.async_call(
  86. DOMAIN, "set_code",
  87. {"lock_entity_id": LOCK_ENTITY_ID, "code": "5678"},
  88. blocking=True,
  89. )
  90. service_data = set_lock_usercode.call_args[0][0].data
  91. assert service_data["code_slot"] == 3
  92. async def test_set_code_auto_assign_skips_occupied(hass, service_env, mock_node):
  93. """If slot 3 is also occupied, auto-assign picks 4."""
  94. node_with_3 = FakeNode(users=[
  95. FakeUserData(1), FakeUserData(2), FakeUserData(3),
  96. ])
  97. coord, set_lock_usercode, _ = service_env
  98. # Refresh to get node_with_3 slots into coordinator state.
  99. with patch(_ZWAVE_HELPER, return_value=node_with_3):
  100. await coord.async_refresh_lock(LOCK_ENTITY_ID)
  101. await hass.services.async_call(
  102. DOMAIN, "set_code",
  103. {"lock_entity_id": LOCK_ENTITY_ID, "code": "9999"},
  104. blocking=True,
  105. )
  106. service_data = set_lock_usercode.call_args[0][0].data
  107. assert service_data["code_slot"] == 4
  108. async def test_set_code_with_name_saves_name(hass, service_env, mock_node):
  109. coord, _, _ = service_env
  110. with patch(_ZWAVE_HELPER, return_value=mock_node):
  111. await hass.services.async_call(
  112. DOMAIN, "set_code",
  113. {"lock_entity_id": LOCK_ENTITY_ID, "slot": 5, "code": "4321", "name": "Charlie"},
  114. blocking=True,
  115. )
  116. assert coord.get_name(LOCK_ENTITY_ID, 5) == "Charlie"
  117. async def test_set_code_unknown_lock_raises(hass, service_env):
  118. with pytest.raises(ServiceValidationError):
  119. await hass.services.async_call(
  120. DOMAIN, "set_code",
  121. {"lock_entity_id": "lock.unknown", "slot": 1, "code": "1234"},
  122. blocking=True,
  123. )
  124. # ---------------------------------------------------------------------------
  125. # clear_code
  126. # ---------------------------------------------------------------------------
  127. async def test_clear_code_calls_zwave_service(hass, service_env, mock_node):
  128. _, _, clear_lock_usercode = service_env
  129. with patch(_ZWAVE_HELPER, return_value=mock_node):
  130. await hass.services.async_call(
  131. DOMAIN, "clear_code",
  132. {"lock_entity_id": LOCK_ENTITY_ID, "slot": 2},
  133. blocking=True,
  134. )
  135. clear_lock_usercode.assert_awaited_once()
  136. service_data = clear_lock_usercode.call_args[0][0].data
  137. assert service_data["code_slot"] == 2
  138. assert service_data["entity_id"] == LOCK_ENTITY_ID
  139. # ---------------------------------------------------------------------------
  140. # set_name
  141. # ---------------------------------------------------------------------------
  142. async def test_set_name_updates_coordinator(hass, service_env):
  143. coord, _, _ = service_env
  144. await hass.services.async_call(
  145. DOMAIN, "set_name",
  146. {"lock_entity_id": LOCK_ENTITY_ID, "slot": 1, "name": "Diana"},
  147. blocking=True,
  148. )
  149. assert coord.get_name(LOCK_ENTITY_ID, 1) == "Diana"
  150. # ---------------------------------------------------------------------------
  151. # set_slot_enabled
  152. # ---------------------------------------------------------------------------
  153. async def test_set_slot_enabled_calls_node(hass, service_env, mock_node):
  154. with patch(_ZWAVE_HELPER, return_value=mock_node):
  155. await hass.services.async_call(
  156. DOMAIN, "set_slot_enabled",
  157. {"lock_entity_id": LOCK_ENTITY_ID, "slot": 1, "enabled": False},
  158. blocking=True,
  159. )
  160. mock_node.access_control.set_user.assert_awaited_once()
  161. slot_arg, opts_arg = mock_node.access_control.set_user.call_args[0]
  162. assert slot_arg == 1
  163. assert opts_arg.active is False
  164. async def test_set_slot_enabled_true(hass, service_env, mock_node):
  165. with patch(_ZWAVE_HELPER, return_value=mock_node):
  166. await hass.services.async_call(
  167. DOMAIN, "set_slot_enabled",
  168. {"lock_entity_id": LOCK_ENTITY_ID, "slot": 2, "enabled": True},
  169. blocking=True,
  170. )
  171. _, opts_arg = mock_node.access_control.set_user.call_args[0]
  172. assert opts_arg.active is True
  173. # ---------------------------------------------------------------------------
  174. # Group targeting: fan-out and target validation
  175. # ---------------------------------------------------------------------------
  176. async def test_set_code_group_fanout(hass, group_service_env):
  177. _, _, set_lock_usercode, _ = group_service_env
  178. await hass.services.async_call(
  179. DOMAIN, "set_code",
  180. {"group_name": "All Locks", "slot": 5, "code": "1234"},
  181. blocking=True,
  182. )
  183. assert set_lock_usercode.await_count == 2
  184. entity_ids = {c[0][0].data["entity_id"] for c in set_lock_usercode.call_args_list}
  185. assert entity_ids == {LOCK_ENTITY_ID, LOCK_2}
  186. async def test_set_code_group_auto_assign_uses_union_of_members(hass, group_service_env):
  187. """LOCK_ENTITY_ID has slot 1 occupied, LOCK_2 has slot 2 — union picks 3 for both."""
  188. _, _, set_lock_usercode, _ = group_service_env
  189. await hass.services.async_call(
  190. DOMAIN, "set_code",
  191. {"group_name": "All Locks", "code": "5678"},
  192. blocking=True,
  193. )
  194. slots_used = {c[0][0].data["code_slot"] for c in set_lock_usercode.call_args_list}
  195. assert slots_used == {3}
  196. async def test_clear_code_group_fanout(hass, group_service_env):
  197. _, _, _, clear_lock_usercode = group_service_env
  198. await hass.services.async_call(
  199. DOMAIN, "clear_code",
  200. {"group_name": "All Locks", "slot": 1},
  201. blocking=True,
  202. )
  203. assert clear_lock_usercode.await_count == 2
  204. entity_ids = {c[0][0].data["entity_id"] for c in clear_lock_usercode.call_args_list}
  205. assert entity_ids == {LOCK_ENTITY_ID, LOCK_2}
  206. async def test_set_name_group_fanout(hass, group_service_env):
  207. coord, _, _, _ = group_service_env
  208. await hass.services.async_call(
  209. DOMAIN, "set_name",
  210. {"group_name": "All Locks", "slot": 1, "name": "Everyone"},
  211. blocking=True,
  212. )
  213. assert coord.get_name(LOCK_ENTITY_ID, 1) == "Everyone"
  214. assert coord.get_name(LOCK_2, 1) == "Everyone"
  215. async def test_set_slot_enabled_group_fanout(hass, group_service_env):
  216. coord, nodes, _, _ = group_service_env
  217. await hass.services.async_call(
  218. DOMAIN, "set_slot_enabled",
  219. {"group_name": "All Locks", "slot": 1, "enabled": False},
  220. blocking=True,
  221. )
  222. nodes[LOCK_ENTITY_ID].access_control.set_user.assert_awaited_once()
  223. nodes[LOCK_2].access_control.set_user.assert_awaited_once()
  224. async def test_group_fanout_continues_after_one_member_fails(hass, group_service_env):
  225. """One lock erroring must not block the operation from reaching the others."""
  226. coord, nodes, _, _ = group_service_env
  227. nodes[LOCK_ENTITY_ID].access_control.set_user.side_effect = RuntimeError("boom")
  228. await hass.services.async_call(
  229. DOMAIN, "set_slot_enabled",
  230. {"group_name": "All Locks", "slot": 1, "enabled": False},
  231. blocking=True,
  232. )
  233. nodes[LOCK_ENTITY_ID].access_control.set_user.assert_awaited_once()
  234. nodes[LOCK_2].access_control.set_user.assert_awaited_once()
  235. async def test_unknown_group_raises(hass, group_service_env):
  236. with pytest.raises(ServiceValidationError):
  237. await hass.services.async_call(
  238. DOMAIN, "set_code",
  239. {"group_name": "Nonexistent", "slot": 1, "code": "1234"},
  240. blocking=True,
  241. )
  242. async def test_both_lock_and_group_target_rejected(hass, group_service_env):
  243. with pytest.raises(vol.Invalid):
  244. await hass.services.async_call(
  245. DOMAIN, "set_code",
  246. {"lock_entity_id": LOCK_ENTITY_ID, "group_name": "All Locks", "slot": 1, "code": "1234"},
  247. blocking=True,
  248. )
  249. async def test_neither_lock_nor_group_target_rejected(hass, group_service_env):
  250. with pytest.raises(vol.Invalid):
  251. await hass.services.async_call(
  252. DOMAIN, "set_code",
  253. {"slot": 1, "code": "1234"},
  254. blocking=True,
  255. )
  256. # ---------------------------------------------------------------------------
  257. # copy_slots
  258. # ---------------------------------------------------------------------------
  259. @pytest.fixture
  260. async def copy_slots_env(hass):
  261. """Two locks (source populated, target empty), services registered."""
  262. entry = MockConfigEntry(
  263. domain=DOMAIN,
  264. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
  265. options={},
  266. entry_id="test_copy_slots_entry",
  267. )
  268. entry.add_to_hass(hass)
  269. nodes = {
  270. LOCK_ENTITY_ID: FakeNode(
  271. users=[FakeUserData(1, active=True), FakeUserData(2, active=False)],
  272. credentials={1: "1234", 2: "5678"},
  273. ),
  274. LOCK_2: FakeNode(users=[FakeUserData(9, active=True)], credentials={9: "0000"}),
  275. }
  276. def _node_for(hass_, entity_id):
  277. return nodes[entity_id]
  278. with patch(_ZWAVE_HELPER, side_effect=_node_for):
  279. coord = ZLockCoordinator(hass, entry)
  280. await coord.async_setup()
  281. hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coord
  282. await coord.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
  283. register_services(hass)
  284. with patch(_ZWAVE_HELPER, side_effect=_node_for):
  285. yield coord, nodes
  286. unregister_services(hass)
  287. coord.async_teardown()
  288. async def test_copy_slots_mirrors_users_and_codes(hass, copy_slots_env):
  289. coord, nodes = copy_slots_env
  290. await hass.services.async_call(
  291. DOMAIN, "copy_slots",
  292. {"source_lock_entity_id": LOCK_ENTITY_ID, "target_lock_entity_id": LOCK_2},
  293. blocking=True,
  294. )
  295. target_ac = nodes[LOCK_2].access_control
  296. target_ids = {u.user_id for u in await target_ac.get_users()}
  297. assert target_ids == {1, 2}
  298. target_slot1 = next(u for u in await target_ac.get_users() if u.user_id == 1)
  299. assert target_slot1.active is True
  300. target_slot2 = next(u for u in await target_ac.get_users() if u.user_id == 2)
  301. assert target_slot2.active is False
  302. assert target_ac._credentials[1] == "1234"
  303. assert target_ac._credentials[2] == "5678"
  304. assert coord.get_name(LOCK_2, 1) == "Alice"
  305. async def test_copy_slots_deletes_target_only_slots(hass, copy_slots_env):
  306. """Target's slot 9 has no counterpart on the source and must be removed."""
  307. coord, nodes = copy_slots_env
  308. await hass.services.async_call(
  309. DOMAIN, "copy_slots",
  310. {"source_lock_entity_id": LOCK_ENTITY_ID, "target_lock_entity_id": LOCK_2},
  311. blocking=True,
  312. )
  313. target_ac = nodes[LOCK_2].access_control
  314. target_ids = {u.user_id for u in await target_ac.get_users()}
  315. assert 9 not in target_ids
  316. assert 9 not in target_ac._credentials
  317. async def test_copy_slots_rejects_same_source_and_target(hass, copy_slots_env):
  318. with pytest.raises(ServiceValidationError):
  319. await hass.services.async_call(
  320. DOMAIN, "copy_slots",
  321. {"source_lock_entity_id": LOCK_ENTITY_ID, "target_lock_entity_id": LOCK_ENTITY_ID},
  322. blocking=True,
  323. )
  324. async def test_copy_slots_rejects_unmanaged_lock(hass, copy_slots_env):
  325. with pytest.raises(ServiceValidationError):
  326. await hass.services.async_call(
  327. DOMAIN, "copy_slots",
  328. {"source_lock_entity_id": LOCK_ENTITY_ID, "target_lock_entity_id": "lock.unknown"},
  329. blocking=True,
  330. )