"""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"