test_coordinator.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. from datetime import date, datetime, time, timezone
  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_API_TOKEN,
  11. CONF_CALDAV_URL,
  12. CONF_CALENDAR_NAME,
  13. CONF_SYNC_MODE,
  14. DOMAIN,
  15. STORE_CURRENT,
  16. STORE_NEXT,
  17. SYNC_MODE_API,
  18. SYNC_MODE_CALDAV,
  19. SYNC_MODE_MANUAL,
  20. )
  21. from custom_components.room_cal_ha.coordinator import RoomCalCoordinator
  22. from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
  23. SAMPLE = ReservationData(
  24. guest_name="Alice",
  25. check_in_date=date(2026, 7, 1),
  26. check_in_time=time(14, 0),
  27. check_out_date=date(2026, 7, 5),
  28. check_out_time=time(12, 0),
  29. wifi_key="stone-angel-market",
  30. door_code="0472",
  31. )
  32. _BASE_DATA = {
  33. "room_state_name": "Room State",
  34. "current_name": "Current",
  35. "next_name": "Next",
  36. }
  37. def _make_entry(hass, options: dict) -> MockConfigEntry:
  38. entry = MockConfigEntry(domain=DOMAIN, data=_BASE_DATA, options=options)
  39. entry.add_to_hass(hass)
  40. return entry
  41. @pytest.fixture
  42. async def coordinator(hass: HomeAssistant, mock_entry):
  43. mock_entry.add_to_hass(hass)
  44. coord = RoomCalCoordinator(hass, mock_entry)
  45. await coord.async_setup()
  46. return coord
  47. # ---------------------------------------------------------------------------
  48. # Manual mode
  49. # ---------------------------------------------------------------------------
  50. async def test_manual_mode_serves_empty(coordinator: RoomCalCoordinator):
  51. await coordinator.async_refresh()
  52. assert coordinator.data.current == ReservationData()
  53. assert coordinator.data.next == ReservationData()
  54. assert coordinator.data.current_last_refresh is None
  55. assert coordinator.data.next_last_refresh is None
  56. assert coordinator.data.api_status is None
  57. # ---------------------------------------------------------------------------
  58. # CalDAV mode
  59. # ---------------------------------------------------------------------------
  60. async def test_caldav_fetch_writes_store_and_timestamps(hass: HomeAssistant):
  61. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  62. coord = RoomCalCoordinator(hass, entry)
  63. await coord.async_setup()
  64. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  65. MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  66. await coord.async_refresh()
  67. assert coord.data.current.guest_name == "Alice"
  68. assert coord.data.current.door_code == "0472"
  69. assert coord.data.next == ReservationData()
  70. assert coord.data.current_last_refresh is not None
  71. assert coord.data.next_last_refresh is not None
  72. async def test_caldav_empty_result_clears_store(hass: HomeAssistant):
  73. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  74. coord = RoomCalCoordinator(hass, entry)
  75. await coord.async_setup()
  76. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  77. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  78. MockBackend.return_value.async_fetch = AsyncMock(return_value=(None, None))
  79. await coord.async_refresh()
  80. assert coord.data.current == ReservationData()
  81. assert coord.store.get(STORE_CURRENT) is None
  82. async def test_caldav_connect_error_preserves_store(hass: HomeAssistant):
  83. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  84. coord = RoomCalCoordinator(hass, entry)
  85. await coord.async_setup()
  86. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  87. with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
  88. MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
  89. await coord.async_refresh()
  90. assert coord.data.current.guest_name == "Alice"
  91. assert coord.data.current_last_refresh is None
  92. async def test_caldav_auth_error_triggers_reauth(hass: HomeAssistant):
  93. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
  94. coord = RoomCalCoordinator(hass, entry)
  95. await coord.async_setup()
  96. with (
  97. patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend,
  98. patch.object(entry, "async_start_reauth") as mock_reauth,
  99. ):
  100. MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendAuthError)
  101. await coord.async_refresh()
  102. mock_reauth.assert_called_once_with(hass)
  103. # ---------------------------------------------------------------------------
  104. # API mode
  105. # ---------------------------------------------------------------------------
  106. SAMPLE_STATUS = ApiStatusData(
  107. status="ok",
  108. checks={"caldav_connection": {"ok": True}, "calendar": {"ok": True}},
  109. )
  110. async def test_api_fetch_writes_store_and_status(hass: HomeAssistant):
  111. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  112. coord = RoomCalCoordinator(hass, entry)
  113. await coord.async_setup()
  114. with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
  115. instance = MockBackend.return_value
  116. instance.async_fetch = AsyncMock(return_value=(SAMPLE, None))
  117. instance.async_fetch_status = AsyncMock(return_value=SAMPLE_STATUS)
  118. instance.async_fetch_services = AsyncMock(return_value={})
  119. await coord.async_refresh()
  120. assert coord.data.current.guest_name == "Alice"
  121. assert coord.data.api_status.status == "ok"
  122. assert coord.data.current_last_refresh is not None
  123. async def test_api_connect_error_preserves_store_and_still_fetches_status(hass: HomeAssistant):
  124. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  125. coord = RoomCalCoordinator(hass, entry)
  126. await coord.async_setup()
  127. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  128. degraded = ApiStatusData(status="error")
  129. with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend:
  130. instance = MockBackend.return_value
  131. instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
  132. instance.async_fetch_status = AsyncMock(return_value=degraded)
  133. instance.async_fetch_services = AsyncMock(return_value={})
  134. await coord.async_refresh()
  135. assert coord.data.current.guest_name == "Alice"
  136. assert coord.data.current_last_refresh is None
  137. assert coord.data.api_status.status == "error"
  138. async def test_api_auth_error_triggers_reauth(hass: HomeAssistant):
  139. entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"})
  140. coord = RoomCalCoordinator(hass, entry)
  141. await coord.async_setup()
  142. with (
  143. patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend,
  144. patch.object(entry, "async_start_reauth") as mock_reauth,
  145. ):
  146. instance = MockBackend.return_value
  147. instance.async_fetch = AsyncMock(side_effect=BackendAuthError)
  148. instance.async_fetch_status = AsyncMock(return_value=ApiStatusData(status="auth_error"))
  149. instance.async_fetch_services = AsyncMock(return_value={})
  150. await coord.async_refresh()
  151. mock_reauth.assert_called_once_with(hass)
  152. # ---------------------------------------------------------------------------
  153. # Thin updates
  154. # ---------------------------------------------------------------------------
  155. async def test_thin_update_overlays_fields(coordinator: RoomCalCoordinator):
  156. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  157. await coordinator.async_refresh()
  158. await coordinator.async_apply_update(STORE_CURRENT, {"wifi_ssid": "GuestNet", "door_code": "9999"})
  159. assert coordinator.data.current.wifi_ssid == "GuestNet"
  160. assert coordinator.data.current.door_code == "9999"
  161. assert coordinator.data.current.guest_name == "Alice"
  162. assert coordinator.data.current.wifi_key == "stone-angel-market"
  163. async def test_thin_update_updates_last_refresh(coordinator: RoomCalCoordinator):
  164. await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
  165. await coordinator.async_refresh()
  166. assert coordinator.data.current_last_refresh is None
  167. await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "1234"})
  168. assert coordinator.data.current_last_refresh is not None
  169. # ---------------------------------------------------------------------------
  170. # Store persistence
  171. # ---------------------------------------------------------------------------
  172. async def test_store_persists_across_reload(hass: HomeAssistant, mock_entry):
  173. mock_entry.add_to_hass(hass)
  174. coord = RoomCalCoordinator(hass, mock_entry)
  175. await coord.async_setup()
  176. await coord.store.async_set(STORE_CURRENT, SAMPLE)
  177. coord2 = RoomCalCoordinator(hass, mock_entry)
  178. await coord2.async_setup()
  179. loaded = coord2.store.get(STORE_CURRENT)
  180. assert loaded is not None
  181. assert loaded.guest_name == "Alice"
  182. assert loaded.door_code == "0472"
  183. # ---------------------------------------------------------------------------
  184. # _reservation_window_state
  185. # ---------------------------------------------------------------------------
  186. _UTC = timezone.utc
  187. DATED = ReservationData(
  188. check_in_date=date(2026, 7, 1),
  189. check_in_time=time(14, 0),
  190. check_out_date=date(2026, 7, 5),
  191. check_out_time=time(12, 0),
  192. )
  193. def _state_at(coordinator, res, dt_str):
  194. """Call _reservation_window_state with dt_util.now() frozen to dt_str (local naive → UTC)."""
  195. frozen = datetime.fromisoformat(dt_str).replace(tzinfo=_UTC)
  196. with patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=frozen):
  197. return coordinator._reservation_window_state(res)
  198. async def test_window_state_active(coordinator):
  199. assert _state_at(coordinator, DATED, "2026-07-03T10:00:00") == "active"
  200. async def test_window_state_pending(coordinator):
  201. assert _state_at(coordinator, DATED, "2026-06-30T10:00:00") == "pending"
  202. async def test_window_state_elapsed(coordinator):
  203. assert _state_at(coordinator, DATED, "2026-07-10T12:00:00") == "elapsed"
  204. async def test_window_state_no_dates_returns_none(coordinator):
  205. assert coordinator._reservation_window_state(ReservationData()) is None
  206. # ---------------------------------------------------------------------------
  207. # Check-in WiFi key behavior
  208. # ---------------------------------------------------------------------------
  209. _WIFI_NEXT = ReservationData(
  210. guest_name="Bob",
  211. check_in_date=date(2026, 7, 1),
  212. check_in_time=time(14, 0),
  213. check_out_date=date(2026, 7, 5),
  214. check_out_time=time(12, 0),
  215. wifi_ssid="GuestNet",
  216. wifi_key="securepass",
  217. )
  218. async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCalCoordinator):
  219. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  220. mock_switch = MagicMock()
  221. mock_switch.async_turn_on = AsyncMock()
  222. coordinator._guest_mode_switch = mock_switch
  223. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  224. await coordinator._async_do_check_in()
  225. mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass")
  226. mock_switch.async_turn_on.assert_awaited_once()
  227. async def test_check_in_skips_wifi_when_ssid_none(coordinator: RoomCalCoordinator):
  228. await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid=None, wifi_key="securepass"))
  229. coordinator._guest_mode_switch = None
  230. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  231. await coordinator._async_do_check_in()
  232. mock_wifi.assert_not_awaited()
  233. async def test_check_in_skips_wifi_when_key_none(coordinator: RoomCalCoordinator):
  234. await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key=None))
  235. coordinator._guest_mode_switch = None
  236. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
  237. await coordinator._async_do_check_in()
  238. mock_wifi.assert_not_awaited()
  239. async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomCalCoordinator):
  240. await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
  241. mock_switch = MagicMock()
  242. mock_switch.async_turn_on = AsyncMock()
  243. coordinator._guest_mode_switch = mock_switch
  244. with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")):
  245. await coordinator._async_do_check_in()
  246. mock_switch.async_turn_on.assert_awaited_once()