test_api_backend.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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_multi_session(responses: dict):
  33. """Return a mock session that dispatches by URL suffix to (status, json_data) pairs."""
  34. @asynccontextmanager
  35. async def _get(url, *args, **kwargs):
  36. for suffix, (status, json_data) in responses.items():
  37. if url.endswith(suffix):
  38. resp = MagicMock()
  39. resp.status = status
  40. resp.json = AsyncMock(return_value=json_data)
  41. if status >= 400 and status not in (401, 403, 503):
  42. resp.raise_for_status.side_effect = Exception(f"HTTP {status}")
  43. yield resp
  44. return
  45. raise AssertionError(f"Unexpected URL: {url}")
  46. session = MagicMock()
  47. session.get = _get
  48. return session
  49. def _mock_session_raises(exc):
  50. """Return a session whose .get() call raises immediately."""
  51. session = MagicMock()
  52. session.get = MagicMock(side_effect=exc)
  53. return session
  54. def _reservation_slot(guest, checkin, checkout, checkin_time="14:00", checkout_time="11:00"):
  55. return {
  56. "guest_name": guest,
  57. "checkin_date": str(checkin),
  58. "checkin_time": checkin_time,
  59. "checkout_date": str(checkout),
  60. "checkout_time": checkout_time,
  61. "wifi_ssid": "GuestNet",
  62. "wifi_key": "wifikey",
  63. "door_code": "1234",
  64. }
  65. # ---------------------------------------------------------------------------
  66. # _parse_reservation
  67. # ---------------------------------------------------------------------------
  68. def test_parse_reservation_maps_fields():
  69. raw = {
  70. "guest_name": "Alice",
  71. "checkin_date": "2026-07-01",
  72. "checkin_time": "14:00",
  73. "checkout_date": "2026-07-05",
  74. "checkout_time": "11:00",
  75. "wifi_ssid": "GuestNet",
  76. "wifi_key": "secret",
  77. "door_code": "4567",
  78. }
  79. res = _parse_reservation(raw)
  80. assert isinstance(res, ReservationData)
  81. assert res.guest_name == "Alice"
  82. assert res.check_in_date == date(2026, 7, 1)
  83. assert res.check_in_time == time(14, 0)
  84. assert res.check_out_date == date(2026, 7, 5)
  85. assert res.check_out_time == time(11, 0)
  86. assert res.wifi_ssid == "GuestNet"
  87. assert res.wifi_key == "secret"
  88. assert res.door_code == "4567"
  89. def test_parse_reservation_none_returns_none():
  90. assert _parse_reservation(None) is None
  91. def test_parse_reservation_missing_dates_gives_none_fields():
  92. res = _parse_reservation({"guest_name": "Bob"})
  93. assert res.guest_name == "Bob"
  94. assert res.check_in_date is None
  95. assert res.check_in_time is None
  96. assert res.check_out_date is None
  97. assert res.check_out_time is None
  98. # ---------------------------------------------------------------------------
  99. # async_fetch — reservation classification
  100. # ---------------------------------------------------------------------------
  101. async def test_fetch_current_today_no_next(hass: HomeAssistant):
  102. # checkin yesterday at 14:00, checkout tomorrow at 11:00 — window always active
  103. today = date.today()
  104. yesterday = today - timedelta(days=1)
  105. tomorrow = today + timedelta(days=1)
  106. response = {str(today): _reservation_slot("Alice", yesterday, tomorrow)}
  107. session = _mock_session(200, response)
  108. entry = _make_entry()
  109. backend = RoomCalApiBackend(hass, entry)
  110. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  111. current, nxt = await backend.async_fetch()
  112. assert current is not None
  113. assert current.guest_name == "Alice"
  114. assert nxt is None
  115. async def test_fetch_no_current_next_upcoming(hass: HomeAssistant):
  116. today = date.today()
  117. future = today + timedelta(days=5)
  118. future_end = today + timedelta(days=8)
  119. response = {
  120. str(today): None,
  121. str(future): _reservation_slot("Bob", future, future_end),
  122. }
  123. session = _mock_session(200, response)
  124. backend = RoomCalApiBackend(hass, _make_entry())
  125. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  126. current, nxt = await backend.async_fetch()
  127. assert current is None
  128. assert nxt is not None
  129. assert nxt.guest_name == "Bob"
  130. async def test_fetch_both_current_and_next(hass: HomeAssistant):
  131. # Alice: window always active (checked in yesterday, out tomorrow)
  132. # Bob: future (5 days from now)
  133. today = date.today()
  134. yesterday = today - timedelta(days=1)
  135. tomorrow = today + timedelta(days=1)
  136. future = today + timedelta(days=5)
  137. future_end = today + timedelta(days=8)
  138. response = {
  139. str(today): _reservation_slot("Alice", yesterday, tomorrow),
  140. str(future): _reservation_slot("Bob", future, future_end),
  141. }
  142. session = _mock_session(200, response)
  143. backend = RoomCalApiBackend(hass, _make_entry())
  144. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  145. current, nxt = await backend.async_fetch()
  146. assert current.guest_name == "Alice"
  147. assert nxt.guest_name == "Bob"
  148. async def test_fetch_multiday_current_not_duplicated_as_next(hass: HomeAssistant):
  149. """A multi-day stay appearing on multiple date keys should produce one current, not also next."""
  150. # checkin yesterday, checkout in 3 days — window always active regardless of test time
  151. today = date.today()
  152. yesterday = today - timedelta(days=1)
  153. checkout = today + timedelta(days=3)
  154. slot = _reservation_slot("Alice", yesterday, checkout)
  155. # Same reservation appears on each day it spans
  156. response = {
  157. str(today): slot,
  158. str(today + timedelta(days=1)): slot,
  159. str(today + timedelta(days=2)): slot,
  160. }
  161. session = _mock_session(200, response)
  162. backend = RoomCalApiBackend(hass, _make_entry())
  163. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  164. current, nxt = await backend.async_fetch()
  165. assert current.guest_name == "Alice"
  166. assert nxt is None
  167. async def test_fetch_current_not_promoted_before_checkin_time(hass: HomeAssistant):
  168. """Next guest's slot should not become current just because their check-in DATE is today."""
  169. # Guest A: checked in 2 days ago, checks out today at 23:59 — window active
  170. # Guest B: checks in today at 14:00 — should be next, not current
  171. # This covers the midnight-transition bug where a date-key lookup would wrongly
  172. # promote Guest B to current at midnight of their check-in date.
  173. from datetime import datetime as _dt, time as _time
  174. today = date.today()
  175. two_days_ago = today - timedelta(days=2)
  176. tomorrow = today + timedelta(days=1)
  177. response = {
  178. str(today): _reservation_slot("Alice", two_days_ago, today, checkout_time="23:59"),
  179. str(tomorrow): _reservation_slot("Bob", today, tomorrow, checkin_time="14:00"),
  180. }
  181. # Pin now to 09:00 — before Alice's checkout AND before Bob's check-in
  182. fake_now = _dt.combine(today, _time(9, 0))
  183. session = _mock_session(200, response)
  184. backend = RoomCalApiBackend(hass, _make_entry())
  185. with (
  186. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  187. patch("custom_components.room_cal_ha.backends.api.datetime") as mock_dt,
  188. ):
  189. mock_dt.now.return_value = fake_now
  190. mock_dt.combine = _dt.combine
  191. current, nxt = await backend.async_fetch()
  192. assert current is not None
  193. assert current.guest_name == "Alice"
  194. assert nxt is not None
  195. assert nxt.guest_name == "Bob"
  196. async def test_fetch_empty_response(hass: HomeAssistant):
  197. session = _mock_session(200, {})
  198. backend = RoomCalApiBackend(hass, _make_entry())
  199. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  200. current, nxt = await backend.async_fetch()
  201. assert current is None
  202. assert nxt is None
  203. # ---------------------------------------------------------------------------
  204. # async_fetch — error handling
  205. # ---------------------------------------------------------------------------
  206. async def test_fetch_401_raises_backend_auth_error(hass: HomeAssistant):
  207. session = _mock_session(401, {})
  208. backend = RoomCalApiBackend(hass, _make_entry())
  209. with (
  210. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  211. pytest.raises(BackendAuthError),
  212. ):
  213. await backend.async_fetch()
  214. async def test_fetch_403_raises_backend_auth_error(hass: HomeAssistant):
  215. session = _mock_session(403, {})
  216. backend = RoomCalApiBackend(hass, _make_entry())
  217. with (
  218. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  219. pytest.raises(BackendAuthError),
  220. ):
  221. await backend.async_fetch()
  222. async def test_fetch_network_error_raises_backend_connect_error(hass: HomeAssistant):
  223. session = _mock_session_raises(Exception("connection refused"))
  224. backend = RoomCalApiBackend(hass, _make_entry())
  225. with (
  226. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  227. pytest.raises(BackendConnectError),
  228. ):
  229. await backend.async_fetch()
  230. async def test_fetch_500_raises_backend_connect_error(hass: HomeAssistant):
  231. session = _mock_session(500, {})
  232. backend = RoomCalApiBackend(hass, _make_entry())
  233. with (
  234. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  235. pytest.raises(BackendConnectError),
  236. ):
  237. await backend.async_fetch()
  238. # ---------------------------------------------------------------------------
  239. # async_fetch_status
  240. # ---------------------------------------------------------------------------
  241. async def test_fetch_status_ok(hass: HomeAssistant):
  242. status_body = {
  243. "status": "ok",
  244. "checks": {"store_connected": {"ok": True}},
  245. "checked_at": "2026-07-28T18:52:39.702615+00:00",
  246. }
  247. session = _mock_multi_session({
  248. "/status": (200, status_body),
  249. "/services": (200, []),
  250. })
  251. backend = RoomCalApiBackend(hass, _make_entry())
  252. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  253. result = await backend.async_fetch_status()
  254. assert result.status == "ok"
  255. assert result.stale is False
  256. async def test_fetch_status_503_returns_degraded(hass: HomeAssistant):
  257. """A 503 with a degraded body must be parsed, not swallowed into a connect error.
  258. This is the main signal the API Health sensor exists for: the backend service
  259. reports a specific reason (e.g. stale Booking.com credentials) while still
  260. returning HTTP 503, since some checks failing is considered a degraded-but-
  261. reachable state rather than a connectivity failure.
  262. """
  263. status_body = {
  264. "status": "degraded",
  265. "checks": {
  266. "store_connected": {"ok": True},
  267. "credentials": {
  268. "airbnb": {"ok": True},
  269. "booking": {"ok": False, "reason": "Booking.com credentials are stale — run: refresh-creds"},
  270. },
  271. },
  272. "checked_at": "2026-07-28T18:52:39.702615+00:00",
  273. }
  274. session = _mock_multi_session({
  275. "/status": (503, status_body),
  276. "/services": (200, []),
  277. })
  278. backend = RoomCalApiBackend(hass, _make_entry())
  279. with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
  280. result = await backend.async_fetch_status()
  281. assert result.status == "degraded"
  282. assert result.checks["store_connected"]["ok"] is True
  283. assert result.checks["credentials"]["booking"]["ok"] is False
  284. assert result.checks["credentials"]["booking"]["reason"] == "Booking.com credentials are stale — run: refresh-creds"
  285. from datetime import datetime, timezone
  286. assert result.checked_at == datetime(2026, 7, 28, 18, 52, 39, 702615, tzinfo=timezone.utc)
  287. async def test_fetch_status_401_raises_backend_auth_error(hass: HomeAssistant):
  288. session = _mock_multi_session({"/status": (401, {})})
  289. backend = RoomCalApiBackend(hass, _make_entry())
  290. with (
  291. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  292. pytest.raises(BackendAuthError),
  293. ):
  294. await backend.async_fetch_status()
  295. async def test_fetch_status_500_raises_backend_connect_error(hass: HomeAssistant):
  296. """A genuine server error (not a degraded-status body) still surfaces as a connect error."""
  297. session = _mock_multi_session({"/status": (500, {})})
  298. backend = RoomCalApiBackend(hass, _make_entry())
  299. with (
  300. patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
  301. pytest.raises(BackendConnectError),
  302. ):
  303. await backend.async_fetch_status()