test_caldav_backend.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. """Unit tests for the CalDAV backend: _parse_event and _fetch selection logic."""
  2. import json
  3. from datetime import date, datetime, time, timedelta, timezone
  4. from unittest.mock import MagicMock, patch
  5. import pytest
  6. from homeassistant.core import HomeAssistant
  7. from custom_components.room_cal_ha.backends.caldav import CalDavBackend, _parse_event
  8. from custom_components.room_cal_ha.const import CONF_CALDAV_URL, CONF_CALENDAR_NAME, CONF_USERNAME, CONF_PASSWORD
  9. from custom_components.room_cal_ha.models import ReservationData
  10. # ---------------------------------------------------------------------------
  11. # iCal helpers
  12. # ---------------------------------------------------------------------------
  13. _X_DATA = {
  14. "guest_name": "Alice Smith",
  15. "wifi_ssid": "GuestNetwork",
  16. "wifi_key": "secret-key",
  17. "door_code": "4567",
  18. }
  19. def _make_event(summary: str, dtstart: str, dtend: str, x_data=None, all_day=False):
  20. """Build a mock caldav.Event with the given iCal fields."""
  21. if all_day:
  22. start_line = f"DTSTART;VALUE=DATE:{dtstart}"
  23. end_line = f"DTEND;VALUE=DATE:{dtend}"
  24. else:
  25. start_line = f"DTSTART:{dtstart}Z"
  26. end_line = f"DTEND:{dtend}Z"
  27. lines = [
  28. "BEGIN:VCALENDAR",
  29. "VERSION:2.0",
  30. "BEGIN:VEVENT",
  31. f"SUMMARY:{summary}",
  32. start_line,
  33. end_line,
  34. ]
  35. if x_data is not None:
  36. lines.append(f"X-ROOMCAL-DATA:{json.dumps(x_data)}")
  37. lines += ["END:VEVENT", "END:VCALENDAR", ""]
  38. event = MagicMock()
  39. event.data = "\r\n".join(lines)
  40. return event
  41. # ---------------------------------------------------------------------------
  42. # Valid event
  43. # ---------------------------------------------------------------------------
  44. def test_parse_valid_event_returns_reservation_data(hass: HomeAssistant):
  45. event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
  46. result = _parse_event(event)
  47. assert isinstance(result, ReservationData)
  48. assert result.guest_name == "Alice Smith"
  49. assert result.wifi_ssid == "GuestNetwork"
  50. assert result.wifi_key == "secret-key"
  51. assert result.door_code == "4567"
  52. def test_parse_valid_event_extracts_dates(hass: HomeAssistant):
  53. event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
  54. result = _parse_event(event)
  55. assert result is not None
  56. assert result.check_in_date == date(2026, 7, 1)
  57. assert result.check_out_date == date(2026, 7, 5)
  58. def test_parse_valid_event_extracts_times(hass: HomeAssistant):
  59. from zoneinfo import ZoneInfo
  60. from homeassistant.util import dt as dt_util
  61. dt_util.set_default_time_zone(ZoneInfo("UTC"))
  62. event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
  63. result = _parse_event(event)
  64. assert result is not None
  65. assert result.check_in_time == time(14, 0)
  66. assert result.check_out_time == time(12, 0)
  67. # ---------------------------------------------------------------------------
  68. # Filtering — events that should return None
  69. # ---------------------------------------------------------------------------
  70. def test_parse_wrong_title_prefix_returns_none(hass: HomeAssistant):
  71. event = _make_event("Blocked", "20260701T140000", "20260705T120000", _X_DATA)
  72. assert _parse_event(event) is None
  73. def test_parse_all_day_event_returns_none(hass: HomeAssistant):
  74. event = _make_event("Guest: Alice", "20260701", "20260705", _X_DATA, all_day=True)
  75. assert _parse_event(event) is None
  76. def test_parse_missing_x_data_returns_none(hass: HomeAssistant):
  77. event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", x_data=None)
  78. assert _parse_event(event) is None
  79. def test_parse_invalid_json_in_x_data_returns_none(hass: HomeAssistant):
  80. event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000")
  81. # Manually inject bad JSON into the iCal
  82. event.data = event.data.replace("END:VEVENT", "X-ROOMCAL-DATA:not-valid-json\r\nEND:VEVENT")
  83. assert _parse_event(event) is None
  84. def test_parse_garbage_ical_returns_none(hass: HomeAssistant):
  85. event = MagicMock()
  86. event.data = "this is not ical at all"
  87. # Should not raise — catches all exceptions
  88. result = _parse_event(event)
  89. assert result is None
  90. # ---------------------------------------------------------------------------
  91. # Partial data
  92. # ---------------------------------------------------------------------------
  93. def test_parse_event_missing_dtend_still_returns_result(hass: HomeAssistant):
  94. """DTEND is optional — event without it should still parse (checkout fields will be None)."""
  95. lines = [
  96. "BEGIN:VCALENDAR",
  97. "VERSION:2.0",
  98. "BEGIN:VEVENT",
  99. "SUMMARY:Guest: Alice",
  100. "DTSTART:20260701T140000Z",
  101. f"X-ROOMCAL-DATA:{json.dumps(_X_DATA)}",
  102. "END:VEVENT",
  103. "END:VCALENDAR",
  104. "",
  105. ]
  106. event = MagicMock()
  107. event.data = "\r\n".join(lines)
  108. result = _parse_event(event)
  109. assert result is not None
  110. assert result.check_in_date == date(2026, 7, 1)
  111. assert result.check_out_date is None
  112. assert result.check_out_time is None
  113. def test_parse_event_x_data_missing_optional_fields(hass: HomeAssistant):
  114. """X-ROOMCAL-DATA may omit wifi_ssid / wifi_key / door_code — those become None."""
  115. sparse = {"guest_name": "Bob"}
  116. event = _make_event("Guest: Bob", "20260801T140000", "20260803T120000", sparse)
  117. result = _parse_event(event)
  118. assert result is not None
  119. assert result.guest_name == "Bob"
  120. assert result.wifi_ssid is None
  121. assert result.wifi_key is None
  122. assert result.door_code is None
  123. # ---------------------------------------------------------------------------
  124. # _fetch — current/next classification with time pinning
  125. #
  126. # These guard the datetime-window selection logic against the midnight-boundary
  127. # bug where a date-only comparison would promote an upcoming guest to "current"
  128. # at midnight on their check-in day rather than at their actual check-in time.
  129. # ---------------------------------------------------------------------------
  130. def _make_caldav_entry():
  131. entry = MagicMock()
  132. entry.options = {CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}
  133. entry.data = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}
  134. return entry
  135. def _mock_caldav_client(n_events: int, cal_name: str = "Room"):
  136. """Return a mock DAVClient whose calendar yields n_events placeholder events."""
  137. mock_cal = MagicMock()
  138. mock_cal.get_display_name.return_value = cal_name
  139. mock_cal.date_search.return_value = [MagicMock() for _ in range(n_events)]
  140. mock_principal = MagicMock()
  141. mock_principal.calendars.return_value = [mock_cal]
  142. mock_client = MagicMock()
  143. mock_client.principal.return_value = mock_principal
  144. return mock_client
  145. def _pin_now(dt: datetime):
  146. """Return an aware datetime whose .replace(tzinfo=None) gives dt (naive local)."""
  147. return dt.replace(tzinfo=timezone.utc)
  148. def _fetch_with_pinned_time(hass, reservations: list, now: datetime):
  149. """Run CalDavBackend._fetch() with parsed events and pinned time."""
  150. backend = CalDavBackend(hass, _make_caldav_entry())
  151. with (
  152. patch("custom_components.room_cal_ha.backends.caldav.caldav.DAVClient",
  153. return_value=_mock_caldav_client(len(reservations))),
  154. patch("custom_components.room_cal_ha.backends.caldav.dt_util.now",
  155. return_value=_pin_now(now)),
  156. patch("custom_components.room_cal_ha.backends.caldav._parse_event",
  157. side_effect=reservations),
  158. ):
  159. return backend._fetch()
  160. def test_caldav_fetch_not_promoted_before_checkin_time(hass: HomeAssistant):
  161. """Guest checking in today at 14:00 should be 'next', not 'current', at 09:00 today.
  162. This is the exact midnight-boundary scenario: at 00:00–13:59 on check-in day a
  163. date-only comparison would have wrongly classified the incoming guest as current.
  164. """
  165. today = date(2026, 7, 1)
  166. alice = ReservationData(
  167. guest_name="Alice",
  168. check_in_date=date(2026, 6, 30), check_in_time=time(14, 0),
  169. check_out_date=today, check_out_time=time(23, 59),
  170. )
  171. bob = ReservationData(
  172. guest_name="Bob",
  173. check_in_date=today, check_in_time=time(14, 0),
  174. check_out_date=date(2026, 7, 2), check_out_time=time(11, 0),
  175. )
  176. current, nxt = _fetch_with_pinned_time(hass, [alice, bob], datetime(2026, 7, 1, 9, 0))
  177. assert current is not None and current.guest_name == "Alice"
  178. assert nxt is not None and nxt.guest_name == "Bob"
  179. def test_caldav_fetch_ongoing_stay_remains_current_before_checkout_time(hass: HomeAssistant):
  180. """A guest checking out today at 11:00 should still be current at 09:00 that morning."""
  181. today = date(2026, 7, 1)
  182. alice = ReservationData(
  183. guest_name="Alice",
  184. check_in_date=date(2026, 6, 29), check_in_time=time(14, 0),
  185. check_out_date=today, check_out_time=time(11, 0),
  186. )
  187. current, nxt = _fetch_with_pinned_time(hass, [alice], datetime(2026, 7, 1, 9, 0))
  188. assert current is not None and current.guest_name == "Alice"
  189. assert nxt is None
  190. def test_caldav_fetch_stay_cleared_at_checkout_time(hass: HomeAssistant):
  191. """Exactly at checkout time the departing guest should no longer be current."""
  192. today = date(2026, 7, 1)
  193. alice = ReservationData(
  194. guest_name="Alice",
  195. check_in_date=date(2026, 6, 29), check_in_time=time(14, 0),
  196. check_out_date=today, check_out_time=time(11, 0),
  197. )
  198. current, nxt = _fetch_with_pinned_time(hass, [alice], datetime(2026, 7, 1, 11, 0))
  199. assert current is None
  200. def test_caldav_fetch_no_current_when_between_stays(hass: HomeAssistant):
  201. """Gap between two reservations: no current, next guest correctly identified."""
  202. alice = ReservationData(
  203. guest_name="Alice",
  204. check_in_date=date(2026, 6, 28), check_in_time=time(14, 0),
  205. check_out_date=date(2026, 7, 1), check_out_time=time(11, 0),
  206. )
  207. bob = ReservationData(
  208. guest_name="Bob",
  209. check_in_date=date(2026, 7, 3), check_in_time=time(14, 0),
  210. check_out_date=date(2026, 7, 6), check_out_time=time(11, 0),
  211. )
  212. # 14:00 on July 1 — Alice checked out at 11:00, Bob doesn't arrive until July 3
  213. current, nxt = _fetch_with_pinned_time(hass, [alice, bob], datetime(2026, 7, 1, 14, 0))
  214. assert current is None
  215. assert nxt is not None and nxt.guest_name == "Bob"