test_coordinator.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167
  1. from datetime import date, datetime, time
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from homeassistant.core import HomeAssistant
  5. from homeassistant.exceptions import HomeAssistantError
  6. from pytest_homeassistant_custom_component.common import MockConfigEntry
  7. from custom_components.room_cal_ha.backends import BackendAuthError, BackendConnectError
  8. from custom_components.room_cal_ha.const import (
  9. CONF_API_BASE_URL,
  10. CONF_CALDAV_URL,
  11. CONF_CALENDAR_NAME,
  12. CONF_RESERVATION_DEFAULTS,
  13. CONF_SYNC_MODE,
  14. CONF_ZLOCK_ENTRIES,
  15. CONF_ZWAVE_SLOT,
  16. DOMAIN,
  17. STORE_CURRENT,
  18. STORE_NEXT,
  19. SYNC_MODE_API,
  20. SYNC_MODE_CALDAV,
  21. SYNC_MODE_MANUAL,
  22. )
  23. from custom_components.room_cal_ha.coordinator import RoomCalCoordinator
  24. from custom_components.room_cal_ha.models import OverrideData, ReservationData
  25. SAMPLE = ReservationData(
  26. guest_name="Alice",
  27. check_in_date=date(2026, 7, 1),
  28. check_in_time=time(14, 0),
  29. check_out_date=date(2026, 7, 5),
  30. check_out_time=time(12, 0),
  31. wifi_key="stone-angel-market",
  32. door_code="0472",
  33. )
  34. _BASE_DATA = {
  35. "room_state_name": "Room State",
  36. "current_name": "Current",
  37. "next_name": "Next",
  38. }
  39. def _make_entry(hass, options: dict) -> MockConfigEntry:
  40. entry = MockConfigEntry(domain=DOMAIN, data=_BASE_DATA, options=options)
  41. entry.add_to_hass(hass)
  42. return entry
  43. @pytest.fixture
  44. async def coordinator(hass: HomeAssistant, mock_entry):
  45. mock_entry.add_to_hass(hass)
  46. coord = RoomCalCoordinator(hass, mock_entry)
  47. await coord.async_setup()
  48. return coord
  49. # ---------------------------------------------------------------------------
  50. # Manual mode
  51. # ---------------------------------------------------------------------------
  52. async def test_manual_mode_serves_empty(coordinator: RoomCalCoordinator):
  53. await coordinator.async_refresh()
  54. assert coordinator.data.current == ReservationData()
  55. assert coordinator.data.next == ReservationData()
  56. assert coordinator.data.current_last_refresh is None
  57. assert coordinator.data.next_last_refresh is None
  58. # ---------------------------------------------------------------------------
  59. # CalDAV mode
  60. # ---------------------------------------------------------------------------
  61. async def test_caldav_fetch_writes_store_and_timestamps(hass: HomeAssistant):
  62. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  63. coord = RoomCalCoordinator(hass, entry)
  64. await coord.async_setup()
  65. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  66. MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  67. await coord.async_refresh()
  68. assert coord.data.current.guest_name == "Alice"
  69. assert coord.data.current.door_code == "0472"
  70. assert coord.data.next == ReservationData()
  71. assert coord.data.current_last_refresh is not None
  72. assert coord.data.next_last_refresh is not None
  73. async def test_caldav_empty_result_clears_store(hass: HomeAssistant):
  74. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  75. coord = RoomCalCoordinator(hass, entry)
  76. await coord.async_setup()
  77. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  78. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  79. MockBackend.return_value.async_fetch = AsyncMock(return_value=(None, None))
  80. await coord.async_refresh()
  81. assert coord.data.current == ReservationData()
  82. assert coord.store.get(STORE_CURRENT) is None
  83. async def test_caldav_connect_error_preserves_store(hass: HomeAssistant):
  84. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  85. coord = RoomCalCoordinator(hass, entry)
  86. await coord.async_setup()
  87. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  88. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  89. MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
  90. await coord.async_refresh()
  91. assert coord.data.current.guest_name == "Alice"
  92. assert coord.data.current_last_refresh is None
  93. async def test_caldav_auth_error_triggers_reauth(hass: HomeAssistant):
  94. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  95. coord = RoomCalCoordinator(hass, entry)
  96. await coord.async_setup()
  97. with (
  98. patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend,
  99. patch.object(entry, "async_start_reauth") as mock_reauth,
  100. ):
  101. MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendAuthError)
  102. await coord.async_refresh()
  103. mock_reauth.assert_called_once_with(hass)
  104. # ---------------------------------------------------------------------------
  105. # API mode
  106. # ---------------------------------------------------------------------------
  107. async def test_api_fetch_writes_store(hass: HomeAssistant):
  108. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  109. coord = RoomCalCoordinator(hass, entry)
  110. await coord.async_setup()
  111. with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
  112. instance = MockBackend.return_value
  113. instance.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  114. await coord.async_refresh()
  115. assert coord.data.current.guest_name == "Alice"
  116. assert coord.data.current_last_refresh is not None
  117. async def test_api_connect_error_preserves_store(hass: HomeAssistant):
  118. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  119. coord = RoomCalCoordinator(hass, entry)
  120. await coord.async_setup()
  121. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  122. with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
  123. instance = MockBackend.return_value
  124. instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
  125. await coord.async_refresh()
  126. assert coord.data.current.guest_name == "Alice"
  127. assert coord.data.current_last_refresh is None
  128. async def test_api_auth_error_triggers_reauth(hass: HomeAssistant):
  129. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  130. coord = RoomCalCoordinator(hass, entry)
  131. await coord.async_setup()
  132. with (
  133. patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend,
  134. patch.object(entry, "async_start_reauth") as mock_reauth,
  135. ):
  136. instance = MockBackend.return_value
  137. instance.async_fetch = AsyncMock(side_effect=BackendAuthError)
  138. await coord.async_refresh()
  139. mock_reauth.assert_called_once_with(hass)
  140. # ---------------------------------------------------------------------------
  141. # Thin updates
  142. # ---------------------------------------------------------------------------
  143. async def test_thin_update_overlays_fields(coordinator: RoomCalCoordinator):
  144. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  145. await coordinator.async_refresh()
  146. await coordinator.async_apply_update(STORE_CURRENT, {"wifi_ssid": "GuestNet", "door_code": "9999"})
  147. assert coordinator.data.current.wifi_ssid == "GuestNet"
  148. assert coordinator.data.current.door_code == "9999"
  149. assert coordinator.data.current.guest_name == "Alice"
  150. assert coordinator.data.current.wifi_key == "stone-angel-market"
  151. async def test_thin_update_updates_last_refresh(coordinator: RoomCalCoordinator):
  152. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  153. await coordinator.async_refresh()
  154. assert coordinator.data.current_last_refresh is None
  155. await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "1234"})
  156. assert coordinator.data.current_last_refresh is not None
  157. # ---------------------------------------------------------------------------
  158. # last_updated
  159. # ---------------------------------------------------------------------------
  160. async def test_last_updated_set_when_backend_data_changes(hass: HomeAssistant):
  161. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  162. coord = RoomCalCoordinator(hass, entry)
  163. await coord.async_setup()
  164. next_sample = ReservationData(guest_name="Next")
  165. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  166. MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, next_sample))
  167. await coord.async_refresh()
  168. assert coord.data.current_last_updated is not None
  169. assert coord.data.next_last_updated is not None
  170. async def test_last_updated_not_set_when_backend_data_unchanged(hass: HomeAssistant):
  171. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  172. coord = RoomCalCoordinator(hass, entry)
  173. await coord.async_setup()
  174. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  175. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  176. MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  177. # First refresh populates self.data
  178. await coord.async_refresh()
  179. ts_after_first = coord.data.current_last_updated
  180. # Second refresh returns same data — last_updated must not change
  181. await coord.async_refresh()
  182. assert coord.data.current_last_updated == ts_after_first
  183. async def test_last_updated_set_on_thin_update_with_change(coordinator: RoomCalCoordinator):
  184. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  185. await coordinator.async_refresh()
  186. await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
  187. assert coordinator.data.current_last_updated is not None
  188. async def test_last_updated_not_set_on_thin_update_with_same_value(coordinator: RoomCalCoordinator):
  189. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  190. await coordinator.async_refresh()
  191. # First update sets last_updated
  192. await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
  193. ts = coordinator.data.current_last_updated
  194. # Second update with the same value must not advance last_updated
  195. await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
  196. assert coordinator.data.current_last_updated == ts
  197. async def test_last_updated_set_when_override_starts(coordinator: RoomCalCoordinator):
  198. await coordinator.async_refresh()
  199. coordinator._room_state_switch = None
  200. assert coordinator.data.current_last_updated is None
  201. await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
  202. assert coordinator.data.current_last_updated is not None
  203. async def test_last_updated_set_when_override_ends(coordinator: RoomCalCoordinator):
  204. coordinator._room_state_switch = None
  205. await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
  206. ts_start = coordinator.data.current_last_updated
  207. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  208. coordinator._override = OverrideData(current=ReservationData(guest_name="Override"))
  209. await coordinator.async_end_override()
  210. assert coordinator.data.current_last_updated != ts_start
  211. async def test_last_updated_persists_across_reload(hass: HomeAssistant, mock_entry):
  212. mock_entry.add_to_hass(hass)
  213. coord = RoomCalCoordinator(hass, mock_entry)
  214. await coord.async_setup()
  215. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  216. await coord.store.async_set_last_updated(STORE_CURRENT, datetime(2026, 1, 1, 12, 0, tzinfo=None))
  217. coord2 = RoomCalCoordinator(hass, mock_entry)
  218. await coord2.async_setup()
  219. loaded = coord2.store.get_last_updated(STORE_CURRENT)
  220. assert loaded is not None
  221. assert loaded.year == 2026
  222. # ---------------------------------------------------------------------------
  223. # Store persistence
  224. # ---------------------------------------------------------------------------
  225. async def test_store_persists_across_reload(hass: HomeAssistant, mock_entry):
  226. mock_entry.add_to_hass(hass)
  227. coord = RoomCalCoordinator(hass, mock_entry)
  228. await coord.async_setup()
  229. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  230. coord2 = RoomCalCoordinator(hass, mock_entry)
  231. await coord2.async_setup()
  232. loaded = coord2.store.get(STORE_CURRENT)
  233. assert loaded is not None
  234. assert loaded.guest_name == "Alice"
  235. assert loaded.door_code == "0472"
  236. # ---------------------------------------------------------------------------
  237. # _reservation_window_state
  238. # ---------------------------------------------------------------------------
  239. DATED = ReservationData(
  240. check_in_date=date(2026, 7, 1),
  241. check_in_time=time(14, 0),
  242. check_out_date=date(2026, 7, 5),
  243. check_out_time=time(12, 0),
  244. )
  245. def _state_at(coordinator, res, dt_str):
  246. """Freeze dt_util.now() to a naive local datetime and call _reservation_window_state."""
  247. frozen = MagicMock()
  248. frozen.replace.return_value = datetime.fromisoformat(dt_str) # naive, no TZ
  249. with patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=frozen):
  250. return coordinator._reservation_window_state(res)
  251. async def test_window_state_active(coordinator):
  252. assert _state_at(coordinator, DATED, "2026-07-03T10:00:00") == "active"
  253. async def test_window_state_pending(coordinator):
  254. assert _state_at(coordinator, DATED, "2026-06-30T10:00:00") == "pending"
  255. async def test_window_state_elapsed(coordinator):
  256. assert _state_at(coordinator, DATED, "2026-07-10T12:00:00") == "elapsed"
  257. async def test_window_state_at_check_in_is_active(coordinator):
  258. assert _state_at(coordinator, DATED, "2026-07-01T14:00:00") == "active"
  259. async def test_window_state_at_check_out_is_elapsed(coordinator):
  260. assert _state_at(coordinator, DATED, "2026-07-05T12:00:00") == "elapsed"
  261. async def test_window_state_no_dates_returns_none(coordinator):
  262. assert coordinator._reservation_window_state(ReservationData()) is None
  263. # ---------------------------------------------------------------------------
  264. # Check-in WiFi key behavior
  265. # ---------------------------------------------------------------------------
  266. _WIFI_NEXT = ReservationData(
  267. guest_name="Bob",
  268. check_in_date=date(2026, 7, 1),
  269. check_in_time=time(14, 0),
  270. check_out_date=date(2026, 7, 5),
  271. check_out_time=time(12, 0),
  272. wifi_ssid="GuestNet",
  273. wifi_key="securepass",
  274. )
  275. # Freeze time at the check-in moment so the window registers as active during check-in tests.
  276. _AT_CHECK_IN = MagicMock()
  277. _AT_CHECK_IN.replace.return_value = datetime(2026, 7, 1, 14, 0, 0)
  278. async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  279. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  280. mock_switch = MagicMock()
  281. mock_switch.async_turn_on = AsyncMock()
  282. coordinator._room_state_switch = mock_switch
  283. with (
  284. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  285. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  286. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  287. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  288. ):
  289. await coordinator._async_do_check_in()
  290. coordinator.cancel_mode_sync()
  291. mock_ssid.assert_awaited_once_with(hass, "GuestNet", True)
  292. mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass")
  293. mock_switch.async_turn_on.assert_awaited_once()
  294. async def test_check_in_skips_wifi_when_ssid_none(coordinator: RoomCalCoordinator):
  295. await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid=None, wifi_key="securepass"))
  296. coordinator._room_state_switch = None
  297. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  298. await coordinator._async_do_check_in()
  299. mock_wifi.assert_not_awaited()
  300. async def test_check_in_skips_wifi_when_key_none(coordinator: RoomCalCoordinator):
  301. await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key=None))
  302. coordinator._room_state_switch = None
  303. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  304. await coordinator._async_do_check_in()
  305. mock_wifi.assert_not_awaited()
  306. async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  307. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  308. mock_switch = MagicMock()
  309. mock_switch.async_turn_on = AsyncMock()
  310. coordinator._room_state_switch = mock_switch
  311. with (
  312. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  313. patch.object(coordinator, "_is_use_unifi_on", return_value=False),
  314. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  315. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  316. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  317. ):
  318. await coordinator._async_do_check_in()
  319. coordinator.cancel_mode_sync()
  320. mock_ssid.assert_not_awaited()
  321. mock_wifi.assert_not_awaited()
  322. mock_switch.async_turn_on.assert_awaited_once()
  323. async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomCalCoordinator):
  324. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  325. mock_switch = MagicMock()
  326. mock_switch.async_turn_on = AsyncMock()
  327. coordinator._room_state_switch = mock_switch
  328. with (
  329. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  330. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  331. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock),
  332. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")),
  333. ):
  334. await coordinator._async_do_check_in()
  335. coordinator.cancel_mode_sync()
  336. mock_switch.async_turn_on.assert_awaited_once()
  337. # ---------------------------------------------------------------------------
  338. # Check-out SSID disable behavior
  339. # ---------------------------------------------------------------------------
  340. async def test_check_out_disables_ssid(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  341. await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
  342. mock_switch = MagicMock()
  343. mock_switch.async_turn_off = AsyncMock()
  344. coordinator._room_state_switch = mock_switch
  345. with (
  346. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  347. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  348. ):
  349. await coordinator._async_do_check_out()
  350. mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
  351. mock_switch.async_turn_off.assert_awaited_once()
  352. async def test_check_out_skips_ssid_when_ssid_none(coordinator: RoomCalCoordinator):
  353. await coordinator.store.async_set(STORE_CURRENT, ReservationData(wifi_ssid=None))
  354. coordinator._room_state_switch = None
  355. with patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid:
  356. await coordinator._async_do_check_out()
  357. mock_ssid.assert_not_awaited()
  358. async def test_check_out_skips_ssid_when_use_unifi_off(coordinator: RoomCalCoordinator):
  359. await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
  360. coordinator._room_state_switch = None
  361. with (
  362. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  363. patch.object(coordinator, "_is_use_unifi_on", return_value=False),
  364. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
  365. ):
  366. await coordinator._async_do_check_out()
  367. mock_ssid.assert_not_awaited()
  368. async def test_check_out_ssid_error_still_deactivates_guest_mode(coordinator: RoomCalCoordinator):
  369. await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
  370. mock_switch = MagicMock()
  371. mock_switch.async_turn_off = AsyncMock()
  372. coordinator._room_state_switch = mock_switch
  373. with (
  374. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  375. patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", side_effect=HomeAssistantError("hub down")),
  376. ):
  377. await coordinator._async_do_check_out()
  378. mock_switch.async_turn_off.assert_awaited_once()
  379. # ---------------------------------------------------------------------------
  380. # async_start_override / async_end_override
  381. # ---------------------------------------------------------------------------
  382. _OVERRIDE_RES = ReservationData(guest_name="Override Guest", door_code="1111")
  383. _WIFI_OVERRIDE = ReservationData(guest_name="WiFi Guest", wifi_ssid="GuestNet", wifi_key="secret")
  384. async def test_start_override_sets_current_in_data(coordinator: RoomCalCoordinator):
  385. coordinator._room_state_switch = None
  386. await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
  387. assert coordinator.data.current.guest_name == "Override Guest"
  388. async def test_start_override_sets_override_active_flag(coordinator: RoomCalCoordinator):
  389. coordinator._room_state_switch = None
  390. await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
  391. assert coordinator.data.override_active is True
  392. async def test_start_override_cancels_mode_sync(coordinator: RoomCalCoordinator):
  393. cancel1, cancel2 = MagicMock(), MagicMock()
  394. coordinator._mode_sync_handles = [cancel1, cancel2]
  395. coordinator._room_state_switch = None
  396. await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
  397. cancel1.assert_called_once()
  398. cancel2.assert_called_once()
  399. assert coordinator._mode_sync_handles == []
  400. async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinator):
  401. mock_switch = MagicMock()
  402. mock_switch.async_turn_on = AsyncMock()
  403. coordinator._room_state_switch = mock_switch
  404. with patch.object(coordinator, "_is_mode_sync_on", return_value=True):
  405. await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
  406. mock_switch.async_turn_on.assert_awaited_once()
  407. async def test_start_override_does_not_turn_on_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
  408. mock_switch = MagicMock()
  409. mock_switch.async_turn_on = AsyncMock()
  410. coordinator._room_state_switch = mock_switch
  411. with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
  412. await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
  413. mock_switch.async_turn_on.assert_not_awaited()
  414. async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  415. coordinator._room_state_switch = MagicMock()
  416. coordinator._room_state_switch.async_turn_on = AsyncMock()
  417. with (
  418. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  419. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  420. ):
  421. await coordinator.async_start_override(OverrideData(current=_WIFI_OVERRIDE))
  422. mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
  423. async def test_start_override_skips_wifi_when_mode_sync_off(coordinator: RoomCalCoordinator):
  424. coordinator._room_state_switch = None
  425. with (
  426. patch.object(coordinator, "_is_mode_sync_on", return_value=False),
  427. patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
  428. ):
  429. await coordinator.async_start_override(OverrideData(current=_WIFI_OVERRIDE))
  430. mock_wifi.assert_not_awaited()
  431. async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalCoordinator):
  432. coordinator._room_state_switch = None
  433. no_ssid = ReservationData(wifi_ssid=None, wifi_key="secret")
  434. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  435. await coordinator.async_start_override(OverrideData(current=no_ssid))
  436. mock_wifi.assert_not_awaited()
  437. async def test_start_override_uses_store_next_when_override_next_is_none(coordinator: RoomCalCoordinator):
  438. await coordinator.store.async_set(STORE_NEXT, SAMPLE)
  439. coordinator._room_state_switch = None
  440. await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
  441. assert coordinator.data.next.guest_name == "Alice"
  442. async def test_start_override_uses_override_next_when_provided(coordinator: RoomCalCoordinator):
  443. override_next = ReservationData(guest_name="Override Next")
  444. coordinator._room_state_switch = None
  445. await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES, next=override_next))
  446. assert coordinator.data.next.guest_name == "Override Next"
  447. async def test_start_override_applies_defaults_to_current(hass: HomeAssistant):
  448. entry = MockConfigEntry(
  449. domain=DOMAIN,
  450. data={},
  451. options={
  452. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  453. CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID", "check_out_time": "11:00:00"},
  454. },
  455. )
  456. entry.add_to_hass(hass)
  457. coord = RoomCalCoordinator(hass, entry)
  458. await coord.async_setup()
  459. coord._guest_mode_switch = None
  460. partial_override = ReservationData(guest_name="Override Guest")
  461. await coord.async_start_override(OverrideData(current=partial_override))
  462. assert coord.data.current.guest_name == "Override Guest"
  463. assert coord.data.current.wifi_ssid == "DefaultSSID"
  464. assert coord.data.current.check_out_time == time(11, 0)
  465. async def test_start_override_defaults_do_not_overwrite_explicit_fields(hass: HomeAssistant):
  466. entry = MockConfigEntry(
  467. domain=DOMAIN,
  468. data={},
  469. options={
  470. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  471. CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
  472. },
  473. )
  474. entry.add_to_hass(hass)
  475. coord = RoomCalCoordinator(hass, entry)
  476. await coord.async_setup()
  477. coord._guest_mode_switch = None
  478. explicit_override = ReservationData(guest_name="Override Guest", wifi_ssid="ExplicitSSID")
  479. await coord.async_start_override(OverrideData(current=explicit_override))
  480. assert coord.data.current.wifi_ssid == "ExplicitSSID"
  481. async def test_refresh_during_override_applies_defaults_to_current(hass: HomeAssistant):
  482. entry = MockConfigEntry(
  483. domain=DOMAIN,
  484. data={},
  485. options={
  486. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  487. CONF_RESERVATION_DEFAULTS: {"door_code": "9999"},
  488. },
  489. )
  490. entry.add_to_hass(hass)
  491. coord = RoomCalCoordinator(hass, entry)
  492. await coord.async_setup()
  493. coord._guest_mode_switch = None
  494. partial_override = ReservationData(guest_name="Override Guest")
  495. await coord.async_start_override(OverrideData(current=partial_override))
  496. await coord.async_refresh()
  497. assert coord.data.current.guest_name == "Override Guest"
  498. assert coord.data.current.door_code == "9999"
  499. async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant):
  500. entry = MockConfigEntry(
  501. domain=DOMAIN,
  502. data={},
  503. options={
  504. CONF_SYNC_MODE: SYNC_MODE_MANUAL,
  505. CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
  506. },
  507. )
  508. entry.add_to_hass(hass)
  509. coord = RoomCalCoordinator(hass, entry)
  510. await coord.async_setup()
  511. coord._guest_mode_switch = None
  512. await coord.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
  513. await coord.async_start_override(OverrideData(current=_OVERRIDE_RES))
  514. assert coord.data.next.guest_name == "Next Guest"
  515. assert coord.data.next.wifi_ssid == "DefaultSSID"
  516. async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator):
  517. coordinator._room_state_switch = None
  518. coordinator._override = OverrideData(current=_OVERRIDE_RES)
  519. await coordinator.async_end_override()
  520. assert coordinator.data.override_active is False
  521. async def test_end_override_restores_store_current(coordinator: RoomCalCoordinator):
  522. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  523. coordinator._room_state_switch = None
  524. coordinator._override = OverrideData(current=_OVERRIDE_RES)
  525. await coordinator.async_end_override()
  526. assert coordinator.data.current.guest_name == "Alice"
  527. # ---------------------------------------------------------------------------
  528. # Thin updates during override
  529. # ---------------------------------------------------------------------------
  530. async def test_thin_update_current_during_override_updates_override_not_store(coordinator: RoomCalCoordinator):
  531. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  532. await coordinator.async_refresh()
  533. coordinator._override = OverrideData(current=_OVERRIDE_RES)
  534. await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
  535. assert coordinator.data.current.door_code == "9999"
  536. # Store is unchanged
  537. assert coordinator.store.get(STORE_CURRENT).door_code == "0472"
  538. async def test_thin_update_current_during_override_data_still_shows_override_active(coordinator: RoomCalCoordinator):
  539. await coordinator.async_refresh()
  540. coordinator._override = OverrideData(current=_OVERRIDE_RES)
  541. await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
  542. assert coordinator.data.override_active is True
  543. async def test_thin_update_next_during_override_falls_through_to_store(coordinator: RoomCalCoordinator):
  544. coordinator._override = OverrideData(current=_OVERRIDE_RES) # override.next is None
  545. await coordinator.async_refresh()
  546. await coordinator.async_apply_update(STORE_NEXT, {"guest_name": "Next Guest"})
  547. assert coordinator.store.get(STORE_NEXT).guest_name == "Next Guest"
  548. assert coordinator.data.override_active is True
  549. assert coordinator.data.current.guest_name == "Override Guest"
  550. # ---------------------------------------------------------------------------
  551. # Backend fetch during override preserves override display
  552. # ---------------------------------------------------------------------------
  553. async def test_backend_fetch_during_override_updates_store_but_preserves_override(hass: HomeAssistant):
  554. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  555. coord = RoomCalCoordinator(hass, entry)
  556. await coord.async_setup()
  557. coord._override = OverrideData(current=_OVERRIDE_RES)
  558. backend_data = ReservationData(guest_name="Backend Alice")
  559. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  560. MockBackend.return_value.async_fetch = AsyncMock(return_value=(backend_data, None))
  561. await coord.async_refresh()
  562. # Store was written by the backend
  563. assert coord.store.get(STORE_CURRENT).guest_name == "Backend Alice"
  564. # But displayed data still shows the override
  565. assert coord.data.current.guest_name == "Override Guest"
  566. assert coord.data.override_active is True
  567. # ---------------------------------------------------------------------------
  568. # async_reconcile_from_store (shared helper)
  569. # ---------------------------------------------------------------------------
  570. _ACTIVE = ReservationData(
  571. guest_name="Active Guest",
  572. check_in_date=date(2026, 6, 20),
  573. check_in_time=time(14, 0),
  574. check_out_date=date(2026, 6, 25),
  575. check_out_time=time(12, 0),
  576. )
  577. _ELAPSED = ReservationData(
  578. guest_name="Past Guest",
  579. check_in_date=date(2026, 6, 1),
  580. check_in_time=time(14, 0),
  581. check_out_date=date(2026, 6, 5),
  582. check_out_time=time(12, 0),
  583. )
  584. _FUTURE = ReservationData(
  585. guest_name="Future Guest",
  586. check_in_date=date(2026, 7, 1),
  587. check_in_time=time(14, 0),
  588. check_out_date=date(2026, 7, 5),
  589. check_out_time=time(12, 0),
  590. )
  591. # Freeze time at 2026-06-22 12:00 local (naive)
  592. _RECONCILE_NOW = MagicMock()
  593. _RECONCILE_NOW.replace.return_value = datetime(2026, 6, 22, 12, 0, 0)
  594. async def test_reconcile_active_current_turns_on_guest_mode(coordinator: RoomCalCoordinator):
  595. await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
  596. mock_switch = MagicMock()
  597. mock_switch.async_turn_on = AsyncMock()
  598. mock_switch.async_turn_off = AsyncMock()
  599. coordinator._room_state_switch = mock_switch
  600. with (
  601. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  602. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  603. ):
  604. await coordinator.async_reconcile_from_store()
  605. coordinator.cancel_mode_sync()
  606. mock_switch.async_turn_on.assert_awaited_once()
  607. mock_switch.async_turn_off.assert_not_awaited()
  608. async def test_reconcile_elapsed_current_clears_store_and_turns_off_guest_mode(coordinator: RoomCalCoordinator):
  609. await coordinator.store.async_set(STORE_CURRENT, _ELAPSED)
  610. mock_switch = MagicMock()
  611. mock_switch.async_turn_on = AsyncMock()
  612. mock_switch.async_turn_off = AsyncMock()
  613. coordinator._room_state_switch = mock_switch
  614. with (
  615. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  616. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  617. ):
  618. await coordinator.async_reconcile_from_store()
  619. assert coordinator.store.get(STORE_CURRENT) is None
  620. mock_switch.async_turn_off.assert_awaited_once()
  621. async def test_reconcile_promotes_next_when_check_in_elapsed(coordinator: RoomCalCoordinator):
  622. await coordinator.store.async_set(STORE_NEXT, _ACTIVE)
  623. with (
  624. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  625. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  626. ):
  627. await coordinator.async_reconcile_from_store()
  628. coordinator.cancel_mode_sync()
  629. assert coordinator.store.get(STORE_CURRENT).guest_name == "Active Guest"
  630. async def test_reconcile_turns_off_guest_mode_when_store_already_cleared(coordinator: RoomCalCoordinator):
  631. """Store was cleared (e.g. checkout write ran) but guest-mode-off action never ran."""
  632. # No current reservation in store — it was already cleared before HA was disabled.
  633. mock_switch = MagicMock()
  634. mock_switch.async_turn_on = AsyncMock()
  635. mock_switch.async_turn_off = AsyncMock()
  636. coordinator._room_state_switch = mock_switch
  637. with (
  638. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  639. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  640. ):
  641. await coordinator.async_reconcile_from_store()
  642. coordinator.cancel_mode_sync()
  643. mock_switch.async_turn_off.assert_awaited_once()
  644. mock_switch.async_turn_on.assert_not_awaited()
  645. async def test_reconcile_does_not_touch_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
  646. await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
  647. mock_switch = MagicMock()
  648. mock_switch.async_turn_on = AsyncMock()
  649. mock_switch.async_turn_off = AsyncMock()
  650. coordinator._room_state_switch = mock_switch
  651. with (
  652. patch.object(coordinator, "_is_mode_sync_on", return_value=False),
  653. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
  654. ):
  655. await coordinator.async_reconcile_from_store()
  656. mock_switch.async_turn_on.assert_not_awaited()
  657. mock_switch.async_turn_off.assert_not_awaited()
  658. # ---------------------------------------------------------------------------
  659. # _diff_with_unifi_check
  660. # ---------------------------------------------------------------------------
  661. _ACTIVE_WIFI_RES = ReservationData(
  662. check_in_date=date(2026, 7, 1),
  663. check_in_time=time(14, 0),
  664. check_out_date=date(2026, 7, 5),
  665. check_out_time=time(12, 0),
  666. wifi_ssid="GuestNet",
  667. wifi_key="correct-key",
  668. )
  669. _DURING_WINDOW = datetime(2026, 7, 3, 12, 0, 0)
  670. def _unifi_diff(coordinator, old, new, now, *, enabled, key):
  671. with (
  672. patch.object(coordinator, "_is_use_unifi_on", return_value=True),
  673. patch("custom_components.room_cal_ha.wifi.get_wlan_state", return_value=(enabled, key)),
  674. ):
  675. return coordinator._diff_with_unifi_check(old, new, now)
  676. async def test_unifi_diff_ssid_disabled_adds_checkin_needed(coordinator):
  677. diff = _unifi_diff(coordinator, None, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="correct-key")
  678. assert diff.checkin_needed
  679. async def test_unifi_diff_key_mismatch_adds_wifi_key_changed(coordinator):
  680. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="wrong-key")
  681. assert diff.wifi_key_changed
  682. async def test_unifi_diff_ssid_disabled_and_key_wrong_sets_both(coordinator):
  683. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="wrong-key")
  684. assert diff.checkin_needed
  685. assert diff.wifi_key_changed
  686. async def test_unifi_diff_state_matches_no_extra_flags(coordinator):
  687. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
  688. assert not diff.has_changes
  689. async def test_unifi_diff_skipped_when_use_unifi_off(coordinator):
  690. with (
  691. patch.object(coordinator, "_is_use_unifi_on", return_value=False),
  692. patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
  693. ):
  694. diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW)
  695. mock_get.assert_not_called()
  696. assert not diff.has_changes # base diff: same record, no changes; unifi check skipped
  697. async def test_unifi_diff_skipped_when_window_not_active(coordinator):
  698. before_window = datetime(2026, 6, 30, 12, 0, 0)
  699. with (
  700. patch.object(coordinator, "_is_use_unifi_on", return_value=True),
  701. patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
  702. ):
  703. diff = coordinator._diff_with_unifi_check(None, _ACTIVE_WIFI_RES, before_window)
  704. mock_get.assert_not_called()
  705. assert not diff.has_changes
  706. async def test_unifi_diff_skipped_when_hub_unavailable(coordinator):
  707. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=None, key=None)
  708. assert not diff.has_changes
  709. async def test_unifi_diff_key_check_skipped_when_actual_key_none(coordinator):
  710. # Controller doesn't expose passphrase — only enabled state checked.
  711. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key=None)
  712. assert not diff.has_changes
  713. def _make_zlock_entry(hass, lock_entity_ids: list[str]) -> MockConfigEntry:
  714. zlock = MockConfigEntry(domain="zlock_slots", data={"lock_entity_ids": lock_entity_ids})
  715. zlock.add_to_hass(hass)
  716. return zlock
  717. @pytest.fixture
  718. async def coordinator_with_locks(hass: HomeAssistant):
  719. zlock = _make_zlock_entry(hass, ["lock.front_door"])
  720. entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5})
  721. coord = RoomCalCoordinator(hass, entry)
  722. await coord.async_setup()
  723. return coord
  724. async def test_unifi_diff_preserves_original_diff_flags(coordinator):
  725. # Even when UniFi state is clean, original checkin_needed from the base diff is kept.
  726. old_inactive = ReservationData(wifi_ssid="GuestNet", wifi_key="correct-key") # no dates → not active
  727. diff = _unifi_diff(coordinator, old_inactive, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
  728. assert diff.checkin_needed # from reservation_diff: old not active, new is active
  729. # ---------------------------------------------------------------------------
  730. # Check-in Z-Wave lock behavior
  731. # ---------------------------------------------------------------------------
  732. _ZWAVE_RES = ReservationData(
  733. guest_name="Bob",
  734. check_in_date=date(2026, 7, 1),
  735. check_in_time=time(14, 0),
  736. check_out_date=date(2026, 7, 5),
  737. check_out_time=time(12, 0),
  738. door_code="7890",
  739. )
  740. async def test_check_in_sets_lock_codes(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
  741. await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
  742. mock_switch = MagicMock()
  743. mock_switch.async_turn_on = AsyncMock()
  744. coordinator_with_locks._room_state_switch = mock_switch
  745. with (
  746. patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
  747. patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
  748. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  749. patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
  750. ):
  751. await coordinator_with_locks._async_do_check_in()
  752. coordinator_with_locks.cancel_mode_sync()
  753. mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.front_door", "slot": 5, "code": "7890"})
  754. mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": True})
  755. mock_switch.async_turn_on.assert_awaited_once()
  756. async def test_check_in_sets_codes_on_all_locks(hass: HomeAssistant):
  757. zlock = _make_zlock_entry(hass, ["lock.front", "lock.back"])
  758. entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5})
  759. coord = RoomCalCoordinator(hass, entry)
  760. await coord.async_setup()
  761. await coord.store.async_set(STORE_NEXT, _ZWAVE_RES)
  762. mock_switch = MagicMock()
  763. mock_switch.async_turn_on = AsyncMock()
  764. coord._room_state_switch = mock_switch
  765. with (
  766. patch.object(coord, "_is_mode_sync_on", return_value=True),
  767. patch.object(coord, "_is_use_zwave_on", return_value=True),
  768. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  769. patch.object(coord, "_async_call_service", new_callable=AsyncMock) as mock_call,
  770. ):
  771. await coord._async_do_check_in()
  772. coord.cancel_mode_sync()
  773. mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.front", "slot": 5, "code": "7890"})
  774. mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.back", "slot": 5, "code": "7890"})
  775. mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front", "slot": 5, "enabled": True})
  776. mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.back", "slot": 5, "enabled": True})
  777. async def test_check_in_skips_lock_codes_when_door_code_none(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
  778. no_code = _ZWAVE_RES.__class__(
  779. guest_name="Bob",
  780. check_in_date=_ZWAVE_RES.check_in_date,
  781. check_in_time=_ZWAVE_RES.check_in_time,
  782. check_out_date=_ZWAVE_RES.check_out_date,
  783. check_out_time=_ZWAVE_RES.check_out_time,
  784. door_code=None,
  785. )
  786. await coordinator_with_locks.store.async_set(STORE_NEXT, no_code)
  787. coordinator_with_locks._room_state_switch = None
  788. with (
  789. patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
  790. patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
  791. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  792. patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
  793. ):
  794. await coordinator_with_locks._async_do_check_in()
  795. coordinator_with_locks.cancel_mode_sync()
  796. assert not any(
  797. c.args[:2] == ("zlock_slots", "set_code") for c in mock_call.await_args_list
  798. )
  799. async def test_check_in_skips_lock_codes_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
  800. await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
  801. coordinator_with_locks._room_state_switch = None
  802. with (
  803. patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
  804. patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False),
  805. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  806. patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
  807. ):
  808. await coordinator_with_locks._async_do_check_in()
  809. coordinator_with_locks.cancel_mode_sync()
  810. mock_call.assert_not_awaited()
  811. async def test_check_in_skips_lock_codes_when_no_locks_configured(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  812. # coordinator fixture has no CONF_ZLOCK_ENTRIES in options
  813. await coordinator.store.async_set(STORE_NEXT, _ZWAVE_RES)
  814. coordinator._room_state_switch = None
  815. with (
  816. patch.object(coordinator, "_is_mode_sync_on", return_value=True),
  817. patch.object(coordinator, "_is_use_zwave_on", return_value=True),
  818. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  819. patch.object(coordinator, "_async_call_service", new_callable=AsyncMock) as mock_call,
  820. ):
  821. await coordinator._async_do_check_in()
  822. coordinator.cancel_mode_sync()
  823. mock_call.assert_not_awaited()
  824. async def test_check_in_lock_error_still_activates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
  825. await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
  826. mock_switch = MagicMock()
  827. mock_switch.async_turn_on = AsyncMock()
  828. coordinator_with_locks._room_state_switch = mock_switch
  829. with (
  830. patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
  831. patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
  832. patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
  833. patch.object(coordinator_with_locks, "_async_call_service", side_effect=HomeAssistantError("lock offline")),
  834. ):
  835. await coordinator_with_locks._async_do_check_in()
  836. coordinator_with_locks.cancel_mode_sync()
  837. mock_switch.async_turn_on.assert_awaited_once()
  838. # ---------------------------------------------------------------------------
  839. # Check-out Z-Wave lock behavior
  840. # ---------------------------------------------------------------------------
  841. async def test_check_out_disables_lock_slot(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
  842. await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
  843. mock_switch = MagicMock()
  844. mock_switch.async_turn_off = AsyncMock()
  845. coordinator_with_locks._room_state_switch = mock_switch
  846. with (
  847. patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
  848. patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
  849. patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
  850. ):
  851. await coordinator_with_locks._async_do_check_out()
  852. mock_call.assert_awaited_once_with("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": False})
  853. mock_switch.async_turn_off.assert_awaited_once()
  854. async def test_check_out_skips_lock_slot_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
  855. await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
  856. coordinator_with_locks._room_state_switch = None
  857. with (
  858. patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
  859. patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False),
  860. patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
  861. ):
  862. await coordinator_with_locks._async_do_check_out()
  863. mock_call.assert_not_awaited()
  864. async def test_check_out_lock_error_still_deactivates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
  865. await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
  866. mock_switch = MagicMock()
  867. mock_switch.async_turn_off = AsyncMock()
  868. coordinator_with_locks._room_state_switch = mock_switch
  869. with (
  870. patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
  871. patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
  872. patch.object(coordinator_with_locks, "_async_call_service", side_effect=HomeAssistantError("lock offline")),
  873. ):
  874. await coordinator_with_locks._async_do_check_out()
  875. mock_switch.async_turn_off.assert_awaited_once()