| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- """Unit tests for the CalDAV backend: _parse_event and _fetch selection logic."""
- import json
- from datetime import date, datetime, time, timedelta, timezone
- from unittest.mock import MagicMock, patch
- import pytest
- from homeassistant.core import HomeAssistant
- from custom_components.room_cal_ha.backends.caldav import CalDavBackend, _parse_event
- from custom_components.room_cal_ha.const import CONF_CALDAV_URL, CONF_CALENDAR_NAME, CONF_USERNAME, CONF_PASSWORD
- from custom_components.room_cal_ha.models import ReservationData
- # ---------------------------------------------------------------------------
- # iCal helpers
- # ---------------------------------------------------------------------------
- _X_DATA = {
- "guest_name": "Alice Smith",
- "wifi_ssid": "GuestNetwork",
- "wifi_key": "secret-key",
- "door_code": "4567",
- }
- def _make_event(summary: str, dtstart: str, dtend: str, x_data=None, all_day=False):
- """Build a mock caldav.Event with the given iCal fields."""
- if all_day:
- start_line = f"DTSTART;VALUE=DATE:{dtstart}"
- end_line = f"DTEND;VALUE=DATE:{dtend}"
- else:
- start_line = f"DTSTART:{dtstart}Z"
- end_line = f"DTEND:{dtend}Z"
- lines = [
- "BEGIN:VCALENDAR",
- "VERSION:2.0",
- "BEGIN:VEVENT",
- f"SUMMARY:{summary}",
- start_line,
- end_line,
- ]
- if x_data is not None:
- lines.append(f"X-ROOMCAL-DATA:{json.dumps(x_data)}")
- lines += ["END:VEVENT", "END:VCALENDAR", ""]
- event = MagicMock()
- event.data = "\r\n".join(lines)
- return event
- # ---------------------------------------------------------------------------
- # Valid event
- # ---------------------------------------------------------------------------
- def test_parse_valid_event_returns_reservation_data(hass: HomeAssistant):
- event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
- result = _parse_event(event)
- assert isinstance(result, ReservationData)
- assert result.guest_name == "Alice Smith"
- assert result.wifi_ssid == "GuestNetwork"
- assert result.wifi_key == "secret-key"
- assert result.door_code == "4567"
- def test_parse_valid_event_extracts_dates(hass: HomeAssistant):
- event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
- result = _parse_event(event)
- assert result is not None
- assert result.check_in_date == date(2026, 7, 1)
- assert result.check_out_date == date(2026, 7, 5)
- def test_parse_valid_event_extracts_times(hass: HomeAssistant):
- from zoneinfo import ZoneInfo
- from homeassistant.util import dt as dt_util
- dt_util.set_default_time_zone(ZoneInfo("UTC"))
- event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
- result = _parse_event(event)
- assert result is not None
- assert result.check_in_time == time(14, 0)
- assert result.check_out_time == time(12, 0)
- # ---------------------------------------------------------------------------
- # Filtering — events that should return None
- # ---------------------------------------------------------------------------
- def test_parse_wrong_title_prefix_returns_none(hass: HomeAssistant):
- event = _make_event("Blocked", "20260701T140000", "20260705T120000", _X_DATA)
- assert _parse_event(event) is None
- def test_parse_all_day_event_returns_none(hass: HomeAssistant):
- event = _make_event("Guest: Alice", "20260701", "20260705", _X_DATA, all_day=True)
- assert _parse_event(event) is None
- def test_parse_missing_x_data_returns_none(hass: HomeAssistant):
- event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", x_data=None)
- assert _parse_event(event) is None
- def test_parse_invalid_json_in_x_data_returns_none(hass: HomeAssistant):
- event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000")
- # Manually inject bad JSON into the iCal
- event.data = event.data.replace("END:VEVENT", "X-ROOMCAL-DATA:not-valid-json\r\nEND:VEVENT")
- assert _parse_event(event) is None
- def test_parse_garbage_ical_returns_none(hass: HomeAssistant):
- event = MagicMock()
- event.data = "this is not ical at all"
- # Should not raise — catches all exceptions
- result = _parse_event(event)
- assert result is None
- # ---------------------------------------------------------------------------
- # Partial data
- # ---------------------------------------------------------------------------
- def test_parse_event_missing_dtend_still_returns_result(hass: HomeAssistant):
- """DTEND is optional — event without it should still parse (checkout fields will be None)."""
- lines = [
- "BEGIN:VCALENDAR",
- "VERSION:2.0",
- "BEGIN:VEVENT",
- "SUMMARY:Guest: Alice",
- "DTSTART:20260701T140000Z",
- f"X-ROOMCAL-DATA:{json.dumps(_X_DATA)}",
- "END:VEVENT",
- "END:VCALENDAR",
- "",
- ]
- event = MagicMock()
- event.data = "\r\n".join(lines)
- result = _parse_event(event)
- assert result is not None
- assert result.check_in_date == date(2026, 7, 1)
- assert result.check_out_date is None
- assert result.check_out_time is None
- def test_parse_event_x_data_missing_optional_fields(hass: HomeAssistant):
- """X-ROOMCAL-DATA may omit wifi_ssid / wifi_key / door_code — those become None."""
- sparse = {"guest_name": "Bob"}
- event = _make_event("Guest: Bob", "20260801T140000", "20260803T120000", sparse)
- result = _parse_event(event)
- assert result is not None
- assert result.guest_name == "Bob"
- assert result.wifi_ssid is None
- assert result.wifi_key is None
- assert result.door_code is None
- # ---------------------------------------------------------------------------
- # _fetch — current/next classification with time pinning
- #
- # These guard the datetime-window selection logic against the midnight-boundary
- # bug where a date-only comparison would promote an upcoming guest to "current"
- # at midnight on their check-in day rather than at their actual check-in time.
- # ---------------------------------------------------------------------------
- def _make_caldav_entry():
- entry = MagicMock()
- entry.options = {CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}
- entry.data = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}
- return entry
- def _mock_caldav_client(n_events: int, cal_name: str = "Room"):
- """Return a mock DAVClient whose calendar yields n_events placeholder events."""
- mock_cal = MagicMock()
- mock_cal.get_display_name.return_value = cal_name
- mock_cal.date_search.return_value = [MagicMock() for _ in range(n_events)]
- mock_principal = MagicMock()
- mock_principal.calendars.return_value = [mock_cal]
- mock_client = MagicMock()
- mock_client.principal.return_value = mock_principal
- return mock_client
- def _pin_now(dt: datetime):
- """Return an aware datetime whose .replace(tzinfo=None) gives dt (naive local)."""
- return dt.replace(tzinfo=timezone.utc)
- def _fetch_with_pinned_time(hass, reservations: list, now: datetime):
- """Run CalDavBackend._fetch() with parsed events and pinned time."""
- backend = CalDavBackend(hass, _make_caldav_entry())
- with (
- patch("custom_components.room_cal_ha.backends.caldav.caldav.DAVClient",
- return_value=_mock_caldav_client(len(reservations))),
- patch("custom_components.room_cal_ha.backends.caldav.dt_util.now",
- return_value=_pin_now(now)),
- patch("custom_components.room_cal_ha.backends.caldav._parse_event",
- side_effect=reservations),
- ):
- return backend._fetch()
- def test_caldav_fetch_not_promoted_before_checkin_time(hass: HomeAssistant):
- """Guest checking in today at 14:00 should be 'next', not 'current', at 09:00 today.
- This is the exact midnight-boundary scenario: at 00:00–13:59 on check-in day a
- date-only comparison would have wrongly classified the incoming guest as current.
- """
- today = date(2026, 7, 1)
- alice = ReservationData(
- guest_name="Alice",
- check_in_date=date(2026, 6, 30), check_in_time=time(14, 0),
- check_out_date=today, check_out_time=time(23, 59),
- )
- bob = ReservationData(
- guest_name="Bob",
- check_in_date=today, check_in_time=time(14, 0),
- check_out_date=date(2026, 7, 2), check_out_time=time(11, 0),
- )
- current, nxt = _fetch_with_pinned_time(hass, [alice, bob], datetime(2026, 7, 1, 9, 0))
- assert current is not None and current.guest_name == "Alice"
- assert nxt is not None and nxt.guest_name == "Bob"
- def test_caldav_fetch_ongoing_stay_remains_current_before_checkout_time(hass: HomeAssistant):
- """A guest checking out today at 11:00 should still be current at 09:00 that morning."""
- today = date(2026, 7, 1)
- alice = ReservationData(
- guest_name="Alice",
- check_in_date=date(2026, 6, 29), check_in_time=time(14, 0),
- check_out_date=today, check_out_time=time(11, 0),
- )
- current, nxt = _fetch_with_pinned_time(hass, [alice], datetime(2026, 7, 1, 9, 0))
- assert current is not None and current.guest_name == "Alice"
- assert nxt is None
- def test_caldav_fetch_stay_cleared_at_checkout_time(hass: HomeAssistant):
- """Exactly at checkout time the departing guest should no longer be current."""
- today = date(2026, 7, 1)
- alice = ReservationData(
- guest_name="Alice",
- check_in_date=date(2026, 6, 29), check_in_time=time(14, 0),
- check_out_date=today, check_out_time=time(11, 0),
- )
- current, nxt = _fetch_with_pinned_time(hass, [alice], datetime(2026, 7, 1, 11, 0))
- assert current is None
- def test_caldav_fetch_no_current_when_between_stays(hass: HomeAssistant):
- """Gap between two reservations: no current, next guest correctly identified."""
- alice = ReservationData(
- guest_name="Alice",
- check_in_date=date(2026, 6, 28), check_in_time=time(14, 0),
- check_out_date=date(2026, 7, 1), check_out_time=time(11, 0),
- )
- bob = ReservationData(
- guest_name="Bob",
- check_in_date=date(2026, 7, 3), check_in_time=time(14, 0),
- check_out_date=date(2026, 7, 6), check_out_time=time(11, 0),
- )
- # 14:00 on July 1 — Alice checked out at 11:00, Bob doesn't arrive until July 3
- current, nxt = _fetch_with_pinned_time(hass, [alice, bob], datetime(2026, 7, 1, 14, 0))
- assert current is None
- assert nxt is not None and nxt.guest_name == "Bob"
|