Bladeren bron

Add boundary tests for midnight check-in bug and is_window_active

Direct tests for is_window_active at midnight-before-checkin, checkin
boundary (inclusive), and checkout boundary (exclusive). CalDAV _fetch
selection logic now has time-pinned tests covering the same midnight
scenario previously only tested in the API backend.
ZiB 4 maanden geleden
bovenliggende
commit
5aee443b5f
2 gewijzigde bestanden met toevoegingen van 191 en 5 verwijderingen
  1. 124 4
      tests/test_caldav_backend.py
  2. 67 1
      tests/test_models.py

+ 124 - 4
tests/test_caldav_backend.py

@@ -1,12 +1,13 @@
-"""Unit tests for _parse_event in the CalDAV backend."""
+"""Unit tests for the CalDAV backend: _parse_event and _fetch selection logic."""
 import json
-from datetime import date, time
-from unittest.mock import MagicMock
+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 _parse_event
+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
 
 
@@ -157,3 +158,122 @@ def test_parse_event_x_data_missing_optional_fields(hass: HomeAssistant):
   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"

+ 67 - 1
tests/test_models.py

@@ -2,7 +2,7 @@ from datetime import date, datetime, time
 
 import pytest
 
-from custom_components.room_cal_ha.models import GuestStateDiff, OverrideData, ReservationData, reservation_diff
+from custom_components.room_cal_ha.models import GuestStateDiff, OverrideData, ReservationData, is_window_active, reservation_diff
 
 
 # ---------------------------------------------------------------------------
@@ -285,3 +285,69 @@ def test_guest_state_diff_has_changes_false_when_empty():
 
 def test_guest_state_diff_has_changes_true_for_checkin():
   assert GuestStateDiff(checkin_needed=True).has_changes
+
+
+# ---------------------------------------------------------------------------
+# is_window_active — boundary conditions
+#
+# These directly guard the fix for the midnight-boundary bug where date-only
+# comparisons would make a 14:00 check-in appear "active" at 00:00 that day.
+# ---------------------------------------------------------------------------
+
+def _stay(checkin_date, checkin_time, checkout_date=None, checkout_time=None) -> ReservationData:
+  return ReservationData(
+    check_in_date=checkin_date,
+    check_in_time=checkin_time,
+    check_out_date=checkout_date,
+    check_out_time=checkout_time,
+  )
+
+
+_CHECKIN_DAY = date(2026, 7, 1)
+_CHECKOUT_DAY = date(2026, 7, 5)
+_CHECKIN_TIME = time(14, 0)
+_CHECKOUT_TIME = time(11, 0)
+_STAY = _stay(_CHECKIN_DAY, _CHECKIN_TIME, _CHECKOUT_DAY, _CHECKOUT_TIME)
+
+
+def test_is_window_active_false_at_midnight_before_checkin():
+  # 00:00 on check-in day — hours before the actual check-in time. This is the
+  # exact scenario that triggered the midnight-boundary bug.
+  midnight = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 0, 0)
+  assert is_window_active(_STAY, midnight) is False
+
+
+def test_is_window_active_false_just_before_checkin():
+  one_minute_before = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 13, 59)
+  assert is_window_active(_STAY, one_minute_before) is False
+
+
+def test_is_window_active_true_at_checkin_time():
+  at_checkin = datetime.combine(_CHECKIN_DAY, _CHECKIN_TIME)
+  assert is_window_active(_STAY, at_checkin) is True
+
+
+def test_is_window_active_true_during_stay():
+  mid_stay = datetime(2026, 7, 3, 10, 0)
+  assert is_window_active(_STAY, mid_stay) is True
+
+
+def test_is_window_active_false_at_checkout_time():
+  # Checkout boundary is exclusive: exactly at checkout_time → no longer active.
+  at_checkout = datetime.combine(_CHECKOUT_DAY, _CHECKOUT_TIME)
+  assert is_window_active(_STAY, at_checkout) is False
+
+
+def test_is_window_active_true_just_before_checkout():
+  one_minute_before = datetime(_CHECKOUT_DAY.year, _CHECKOUT_DAY.month, _CHECKOUT_DAY.day, 10, 59)
+  assert is_window_active(_STAY, one_minute_before) is True
+
+
+def test_is_window_active_false_when_no_checkin_time():
+  # Date present but no time — treated as inactive (guards against date-only comparisons).
+  date_only = ReservationData(check_in_date=_CHECKIN_DAY)
+  assert is_window_active(date_only, datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 15, 0)) is False
+
+
+def test_is_window_active_false_when_reservation_is_none():
+  assert is_window_active(None, datetime(2026, 7, 1, 15, 0)) is False