test_api_backend.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. """Unit tests for RoomCalApiBackend."""
  2. from contextlib import asynccontextmanager
  3. from datetime import date, time, timedelta
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from homeassistant.core import HomeAssistant
  7. from custom_components.room_cal_ha.backends import BackendAuthError, BackendConnectError
  8. from custom_components.room_cal_ha.backends.api import RoomCalApiBackend, _parse_reservation
  9. from custom_components.room_cal_ha.const import CONF_API_BASE_URL, CONF_API_TOKEN, CONF_SYNC_MODE, SYNC_MODE_API
  10. from custom_components.room_cal_ha.models import ReservationData
  11. # ---------------------------------------------------------------------------
  12. # Helpers
  13. # ---------------------------------------------------------------------------
  14. def _make_entry(base_url="https://api.example.com", token="test-token"):
  15. entry = MagicMock()
  16. entry.options = {CONF_API_BASE_URL: base_url, CONF_SYNC_MODE: SYNC_MODE_API}
  17. entry.data = {CONF_API_TOKEN: token}
  18. return entry
  19. def _mock_session(status: int, json_data=None):
  20. """Return a mock aiohttp session that yields one response."""
  21. resp = MagicMock()
  22. resp.status = status
  23. resp.json = AsyncMock(return_value=json_data)
  24. if status >= 400 and status not in (401, 403):
  25. resp.raise_for_status.side_effect = Exception(f"HTTP {status}")
  26. @asynccontextmanager
  27. async def _get(*args, **kwargs):
  28. yield resp
  29. session = MagicMock()
  30. session.get = _get
  31. return session
  32. def _mock_session_raises(exc):
  33. """Return a session whose .get() call raises immediately."""
  34. session = MagicMock()
  35. session.get = MagicMock(side_effect=exc)
  36. return session
  37. def _reservation_slot(guest, checkin, checkout, checkin_time="14:00", checkout_time="11:00"):
  38. return {
  39. "guest_name": guest,
  40. "checkin_date": str(checkin),
  41. "checkin_time": checkin_time,
  42. "checkout_date": str(checkout),
  43. "checkout_time": checkout_time,
  44. "wifi_ssid": "GuestNet",
  45. "wifi_key": "wifikey",
  46. "door_code": "1234",
  47. }
  48. # ---------------------------------------------------------------------------
  49. # _parse_reservation
  50. # ---------------------------------------------------------------------------
  51. def test_parse_reservation_maps_fields():
  52. raw = {
  53. "guest_name": "Alice",
  54. "checkin_date": "2026-07-01",
  55. "checkin_time": "14:00",
  56. "checkout_date": "2026-07-05",
  57. "checkout_time": "11:00",
  58. "wifi_ssid": "GuestNet",
  59. "wifi_key": "secret",
  60. "door_code": "4567",
  61. }
  62. res = _parse_reservation(raw)
  63. assert isinstance(res, ReservationData)
  64. assert res.guest_name == "Alice"
  65. assert res.check_in_date == date(2026, 7, 1)
  66. assert res.check_in_time == time(14, 0)
  67. assert res.check_out_date == date(2026, 7, 5)
  68. assert res.check_out_time == time(11, 0)
  69. assert res.wifi_ssid == "GuestNet"
  70. assert res.wifi_key == "secret"
  71. assert res.door_code == "4567"
  72. def test_parse_reservation_none_returns_none():
  73. assert _parse_reservation(None) is None
  74. def test_parse_reservation_missing_dates_gives_none_fields():
  75. res = _parse_reservation({"guest_name": "Bob"})
  76. assert res.guest_name == "Bob"
  77. assert res.check_in_date is None
  78. assert res.check_in_time is None
  79. assert res.check_out_date is None
  80. assert res.check_out_time is None
  81. # ---------------------------------------------------------------------------
  82. # async_fetch — reservation classification
  83. # ---------------------------------------------------------------------------
  84. async def test_fetch_current_today_no_next(hass: HomeAssistant):
  85. # checkin yesterday at 14:00, checkout tomorrow at 11:00 — window always active
  86. today = date.today()
  87. yesterday = today - timedelta(days=1)
  88. tomorrow = today + timedelta(days=1)
  89. response = {str(today): _reservation_slot("Alice", yesterday, tomorrow)}
  90. session = _mock_session(200, response)
  91. entry = _make_entry()
  92. backend = RoomCalApiBackend(hass, entry)
  93. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  94. current, nxt = await backend.async_fetch()
  95. assert current is not None
  96. assert current.guest_name == "Alice"
  97. assert nxt is None
  98. async def test_fetch_no_current_next_upcoming(hass: HomeAssistant):
  99. today = date.today()
  100. future = today + timedelta(days=5)
  101. future_end = today + timedelta(days=8)
  102. response = {
  103. str(today): None,
  104. str(future): _reservation_slot("Bob", future, future_end),
  105. }
  106. session = _mock_session(200, response)
  107. backend = RoomCalApiBackend(hass, _make_entry())
  108. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  109. current, nxt = await backend.async_fetch()
  110. assert current is None
  111. assert nxt is not None
  112. assert nxt.guest_name == "Bob"
  113. async def test_fetch_both_current_and_next(hass: HomeAssistant):
  114. # Alice: window always active (checked in yesterday, out tomorrow)
  115. # Bob: future (5 days from now)
  116. today = date.today()
  117. yesterday = today - timedelta(days=1)
  118. tomorrow = today + timedelta(days=1)
  119. future = today + timedelta(days=5)
  120. future_end = today + timedelta(days=8)
  121. response = {
  122. str(today): _reservation_slot("Alice", yesterday, tomorrow),
  123. str(future): _reservation_slot("Bob", future, future_end),
  124. }
  125. session = _mock_session(200, response)
  126. backend = RoomCalApiBackend(hass, _make_entry())
  127. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  128. current, nxt = await backend.async_fetch()
  129. assert current.guest_name == "Alice"
  130. assert nxt.guest_name == "Bob"
  131. async def test_fetch_multiday_current_not_duplicated_as_next(hass: HomeAssistant):
  132. """A multi-day stay appearing on multiple date keys should produce one current, not also next."""
  133. # checkin yesterday, checkout in 3 days — window always active regardless of test time
  134. today = date.today()
  135. yesterday = today - timedelta(days=1)
  136. checkout = today + timedelta(days=3)
  137. slot = _reservation_slot("Alice", yesterday, checkout)
  138. # Same reservation appears on each day it spans
  139. response = {
  140. str(today): slot,
  141. str(today + timedelta(days=1)): slot,
  142. str(today + timedelta(days=2)): slot,
  143. }
  144. session = _mock_session(200, response)
  145. backend = RoomCalApiBackend(hass, _make_entry())
  146. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  147. current, nxt = await backend.async_fetch()
  148. assert current.guest_name == "Alice"
  149. assert nxt is None
  150. async def test_fetch_current_not_promoted_before_checkin_time(hass: HomeAssistant):
  151. """Next guest's slot should not become current just because their check-in DATE is today."""
  152. # Guest A: checked in 2 days ago, checks out today at 23:59 — window active
  153. # Guest B: checks in today at 14:00 — should be next, not current
  154. # This covers the midnight-transition bug where a date-key lookup would wrongly
  155. # promote Guest B to current at midnight of their check-in date.
  156. from datetime import datetime as _dt, time as _time
  157. today = date.today()
  158. two_days_ago = today - timedelta(days=2)
  159. tomorrow = today + timedelta(days=1)
  160. response = {
  161. str(today): _reservation_slot("Alice", two_days_ago, today, checkout_time="23:59"),
  162. str(tomorrow): _reservation_slot("Bob", today, tomorrow, checkin_time="14:00"),
  163. }
  164. # Pin now to 09:00 — before Alice's checkout AND before Bob's check-in
  165. fake_now = _dt.combine(today, _time(9, 0))
  166. session = _mock_session(200, response)
  167. backend = RoomCalApiBackend(hass, _make_entry())
  168. with (
  169. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  170. patch("custom_components.room_cal_ha.backends.api.datetime") as mock_dt,
  171. ):
  172. mock_dt.now.return_value = fake_now
  173. mock_dt.combine = _dt.combine
  174. current, nxt = await backend.async_fetch()
  175. assert current is not None
  176. assert current.guest_name == "Alice"
  177. assert nxt is not None
  178. assert nxt.guest_name == "Bob"
  179. async def test_fetch_empty_response(hass: HomeAssistant):
  180. session = _mock_session(200, {})
  181. backend = RoomCalApiBackend(hass, _make_entry())
  182. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  183. current, nxt = await backend.async_fetch()
  184. assert current is None
  185. assert nxt is None
  186. # ---------------------------------------------------------------------------
  187. # async_fetch — error handling
  188. # ---------------------------------------------------------------------------
  189. async def test_fetch_401_raises_backend_auth_error(hass: HomeAssistant):
  190. session = _mock_session(401, {})
  191. backend = RoomCalApiBackend(hass, _make_entry())
  192. with (
  193. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  194. pytest.raises(BackendAuthError),
  195. ):
  196. await backend.async_fetch()
  197. async def test_fetch_403_raises_backend_auth_error(hass: HomeAssistant):
  198. session = _mock_session(403, {})
  199. backend = RoomCalApiBackend(hass, _make_entry())
  200. with (
  201. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  202. pytest.raises(BackendAuthError),
  203. ):
  204. await backend.async_fetch()
  205. async def test_fetch_network_error_raises_backend_connect_error(hass: HomeAssistant):
  206. session = _mock_session_raises(Exception("connection refused"))
  207. backend = RoomCalApiBackend(hass, _make_entry())
  208. with (
  209. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  210. pytest.raises(BackendConnectError),
  211. ):
  212. await backend.async_fetch()
  213. async def test_fetch_500_raises_backend_connect_error(hass: HomeAssistant):
  214. session = _mock_session(500, {})
  215. backend = RoomCalApiBackend(hass, _make_entry())
  216. with (
  217. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  218. pytest.raises(BackendConnectError),
  219. ):
  220. await backend.async_fetch()