from datetime import date, datetime, time, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.room_cal_ha.backends import BackendAuthError, BackendConnectError from custom_components.room_cal_ha.const import ( CONF_API_BASE_URL, CONF_CALDAV_URL, CONF_CALENDAR_NAME, CONF_RESERVATION_DEFAULTS, CONF_SYNC_MODE, DOMAIN, STORE_CURRENT, STORE_NEXT, SYNC_MODE_API, SYNC_MODE_CALDAV, SYNC_MODE_MANUAL, ) from custom_components.room_cal_ha.coordinator import RoomCalCoordinator from custom_components.room_cal_ha.models import OverrideData, ReservationData SAMPLE = ReservationData( guest_name="Alice", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), wifi_key="stone-angel-market", door_code="0472", ) _BASE_DATA = { "room_state_name": "Room State", "current_name": "Current", "next_name": "Next", } def _make_entry(hass, options: dict) -> MockConfigEntry: entry = MockConfigEntry(domain=DOMAIN, data=_BASE_DATA, options=options) entry.add_to_hass(hass) return entry @pytest.fixture async def coordinator(hass: HomeAssistant, mock_entry): mock_entry.add_to_hass(hass) coord = RoomCalCoordinator(hass, mock_entry) await coord.async_setup() return coord # --------------------------------------------------------------------------- # Manual mode # --------------------------------------------------------------------------- async def test_manual_mode_serves_empty(coordinator: RoomCalCoordinator): await coordinator.async_refresh() assert coordinator.data.current == ReservationData() assert coordinator.data.next == ReservationData() assert coordinator.data.current_last_refresh is None assert coordinator.data.next_last_refresh is None # --------------------------------------------------------------------------- # CalDAV mode # --------------------------------------------------------------------------- async def test_caldav_fetch_writes_store_and_timestamps(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend: MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None)) await coord.async_refresh() assert coord.data.current.guest_name == "Alice" assert coord.data.current.door_code == "0472" assert coord.data.next == ReservationData() assert coord.data.current_last_refresh is not None assert coord.data.next_last_refresh is not None async def test_caldav_empty_result_clears_store(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() await coord.store.async_set(STORE_CURRENT, SAMPLE) with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend: MockBackend.return_value.async_fetch = AsyncMock(return_value=(None, None)) await coord.async_refresh() assert coord.data.current == ReservationData() assert coord.store.get(STORE_CURRENT) is None async def test_caldav_connect_error_preserves_store(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() await coord.store.async_set(STORE_CURRENT, SAMPLE) with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend: MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout")) await coord.async_refresh() assert coord.data.current.guest_name == "Alice" assert coord.data.current_last_refresh is None async def test_caldav_auth_error_triggers_reauth(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() with ( patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend, patch.object(entry, "async_start_reauth") as mock_reauth, ): MockBackend.return_value.async_fetch = AsyncMock(side_effect=BackendAuthError) await coord.async_refresh() mock_reauth.assert_called_once_with(hass) # --------------------------------------------------------------------------- # API mode # --------------------------------------------------------------------------- async def test_api_fetch_writes_store(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend: instance = MockBackend.return_value instance.async_fetch = AsyncMock(return_value=(SAMPLE, None)) await coord.async_refresh() assert coord.data.current.guest_name == "Alice" assert coord.data.current_last_refresh is not None async def test_api_connect_error_preserves_store(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() await coord.store.async_set(STORE_CURRENT, SAMPLE) with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend: instance = MockBackend.return_value instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout")) await coord.async_refresh() assert coord.data.current.guest_name == "Alice" assert coord.data.current_last_refresh is None async def test_api_auth_error_triggers_reauth(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() with ( patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend, patch.object(entry, "async_start_reauth") as mock_reauth, ): instance = MockBackend.return_value instance.async_fetch = AsyncMock(side_effect=BackendAuthError) await coord.async_refresh() mock_reauth.assert_called_once_with(hass) # --------------------------------------------------------------------------- # Thin updates # --------------------------------------------------------------------------- async def test_thin_update_overlays_fields(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() await coordinator.async_apply_update({"wifi_ssid": "GuestNet", "door_code": "9999"}) assert coordinator.data.active.wifi_ssid == "GuestNet" assert coordinator.data.active.door_code == "9999" assert coordinator.data.active.guest_name == "Alice" assert coordinator.data.active.wifi_key == "stone-angel-market" # Current (pure backend mirror) is unaffected by the patch. assert coordinator.data.current.door_code == "0472" async def test_thin_update_starts_an_override(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() assert coordinator.data.override_active is False await coordinator.async_apply_update({"door_code": "1234"}) assert coordinator.data.override_active is True async def test_thin_update_updates_active_last_refresh(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() assert coordinator.data.active_last_refresh is None await coordinator.async_apply_update({"door_code": "1234"}) assert coordinator.data.active_last_refresh is not None # --------------------------------------------------------------------------- # last_updated # --------------------------------------------------------------------------- async def test_last_updated_set_when_backend_data_changes(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() next_sample = ReservationData(guest_name="Next") with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend: MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, next_sample)) await coord.async_refresh() assert coord.data.current_last_updated is not None assert coord.data.next_last_updated is not None async def test_last_updated_not_set_when_backend_data_unchanged(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() await coord.store.async_set(STORE_CURRENT, SAMPLE) with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend: MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None)) # First refresh populates self.data await coord.async_refresh() ts_after_first = coord.data.current_last_updated # Second refresh returns same data — last_updated must not change await coord.async_refresh() assert coord.data.current_last_updated == ts_after_first async def test_last_updated_set_on_thin_update_with_change(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() await coordinator.async_apply_update({"door_code": "9999"}) assert coordinator.data.active_last_updated is not None async def test_last_updated_not_set_on_thin_update_with_same_value(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() # First update sets last_updated await coordinator.async_apply_update({"door_code": "9999"}) ts = coordinator.data.active_last_updated # Second update with the same value must not advance last_updated await coordinator.async_apply_update({"door_code": "9999"}) assert coordinator.data.active_last_updated == ts async def test_last_updated_set_when_override_starts(coordinator: RoomCalCoordinator): await coordinator.async_refresh() coordinator._room_state_switch = None assert coordinator.data.active_last_updated is None await coordinator.async_start_override(OverrideData(active=ReservationData(guest_name="Override"))) assert coordinator.data.active_last_updated is not None async def test_last_updated_set_when_override_ends(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(active=ReservationData(guest_name="Override"))) ts_start = coordinator.data.active_last_updated await coordinator.store.async_set(STORE_CURRENT, SAMPLE) coordinator._override = OverrideData(active=ReservationData(guest_name="Override")) await coordinator.async_end_override() assert coordinator.data.active_last_updated != ts_start async def test_last_updated_persists_across_reload(hass: HomeAssistant, mock_entry): mock_entry.add_to_hass(hass) coord = RoomCalCoordinator(hass, mock_entry) await coord.async_setup() await coord.store.async_set(STORE_CURRENT, SAMPLE) await coord.store.async_set_last_updated(STORE_CURRENT, datetime(2026, 1, 1, 12, 0, tzinfo=None)) coord2 = RoomCalCoordinator(hass, mock_entry) await coord2.async_setup() loaded = coord2.store.get_last_updated(STORE_CURRENT) assert loaded is not None assert loaded.year == 2026 # --------------------------------------------------------------------------- # Store persistence # --------------------------------------------------------------------------- async def test_store_persists_across_reload(hass: HomeAssistant, mock_entry): mock_entry.add_to_hass(hass) coord = RoomCalCoordinator(hass, mock_entry) await coord.async_setup() await coord.store.async_set(STORE_CURRENT, SAMPLE) coord2 = RoomCalCoordinator(hass, mock_entry) await coord2.async_setup() loaded = coord2.store.get(STORE_CURRENT) assert loaded is not None assert loaded.guest_name == "Alice" assert loaded.door_code == "0472" # --------------------------------------------------------------------------- # _reservation_window_state # --------------------------------------------------------------------------- DATED = ReservationData( check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), ) def _state_at(coordinator, res, dt_str): """Freeze dt_util.now() to a naive local datetime and call _reservation_window_state.""" frozen = MagicMock() frozen.replace.return_value = datetime.fromisoformat(dt_str) # naive, no TZ with patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=frozen): return coordinator._reservation_window_state(res) async def test_window_state_active(coordinator): assert _state_at(coordinator, DATED, "2026-07-03T10:00:00") == "active" async def test_window_state_pending(coordinator): assert _state_at(coordinator, DATED, "2026-06-30T10:00:00") == "pending" async def test_window_state_elapsed(coordinator): assert _state_at(coordinator, DATED, "2026-07-10T12:00:00") == "elapsed" async def test_window_state_at_check_in_is_active(coordinator): assert _state_at(coordinator, DATED, "2026-07-01T14:00:00") == "active" async def test_window_state_at_check_out_is_elapsed(coordinator): assert _state_at(coordinator, DATED, "2026-07-05T12:00:00") == "elapsed" async def test_window_state_no_dates_returns_none(coordinator): assert coordinator._reservation_window_state(ReservationData()) is None # --------------------------------------------------------------------------- # Check-in WiFi key behavior # --------------------------------------------------------------------------- _WIFI_NEXT = ReservationData( guest_name="Bob", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), wifi_ssid="GuestNet", wifi_key="securepass", ) # Freeze time at the check-in moment so the window registers as active during check-in tests. _AT_CHECK_IN = MagicMock() _AT_CHECK_IN.replace.return_value = datetime(2026, 7, 1, 14, 0, 0) # Freeze time at (i.e. slightly after) the scheduled check-out moment — this is what # `dt_util.now()` actually reads when a natural-checkout handler runs in production, and # is the case that exposed the bug where checkout actions require re-deriving "was old # active" from wall-clock time (see reservation_diff in models.py). _AT_CHECK_OUT = MagicMock() _AT_CHECK_OUT.replace.return_value = datetime(2026, 7, 5, 12, 0, 0) async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi, patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid, ): await coordinator._async_do_check_in() coordinator.cancel_mode_sync() mock_ssid.assert_awaited_once_with(hass, "GuestNet", True) mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass") mock_switch.async_turn_on.assert_awaited_once() async def test_check_in_skips_wifi_when_ssid_none(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid=None, wifi_key="securepass")) coordinator._room_state_switch = None with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi: await coordinator._async_do_check_in() mock_wifi.assert_not_awaited() async def test_check_in_skips_wifi_when_key_none(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key=None)) coordinator._room_state_switch = None with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi: await coordinator._async_do_check_in() mock_wifi.assert_not_awaited() async def test_check_in_skips_wifi_when_key_empty_string(coordinator: RoomCalCoordinator): """An open-network default (wifi_key="") should not push a WPA password to UniFi.""" await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key="")) coordinator._room_state_switch = None with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi: await coordinator._async_do_check_in() mock_wifi.assert_not_awaited() async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch.object(coordinator, "_is_use_unifi_on", return_value=False), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid, patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi, ): await coordinator._async_do_check_in() coordinator.cancel_mode_sync() mock_ssid.assert_not_awaited() mock_wifi.assert_not_awaited() mock_switch.async_turn_on.assert_awaited_once() async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock), patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")), ): await coordinator._async_do_check_in() coordinator.cancel_mode_sync() mock_switch.async_turn_on.assert_awaited_once() # --------------------------------------------------------------------------- # Check-out SSID disable behavior # --------------------------------------------------------------------------- async def test_check_out_disables_ssid(hass: HomeAssistant, coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT) mock_switch = MagicMock() mock_switch.async_turn_off = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_OUT), patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid, ): await coordinator._async_do_check_out() mock_ssid.assert_awaited_once_with(hass, "GuestNet", False) mock_switch.async_turn_off.assert_awaited_once() async def test_check_out_skips_ssid_when_ssid_none(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, ReservationData(wifi_ssid=None)) coordinator._room_state_switch = None with patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid: await coordinator._async_do_check_out() mock_ssid.assert_not_awaited() async def test_check_out_skips_ssid_when_use_unifi_off(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT) coordinator._room_state_switch = None with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch.object(coordinator, "_is_use_unifi_on", return_value=False), patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid, ): await coordinator._async_do_check_out() mock_ssid.assert_not_awaited() async def test_check_out_ssid_error_still_deactivates_guest_mode(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT) mock_switch = MagicMock() mock_switch.async_turn_off = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_OUT), patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", side_effect=HomeAssistantError("hub down")), ): await coordinator._async_do_check_out() mock_switch.async_turn_off.assert_awaited_once() # --------------------------------------------------------------------------- # async_start_override / async_end_override # --------------------------------------------------------------------------- _OVERRIDE_RES = ReservationData(guest_name="Override Guest", door_code="1111") _WIFI_OVERRIDE = ReservationData(guest_name="WiFi Guest", wifi_ssid="GuestNet", wifi_key="secret") async def test_start_override_sets_active_in_data(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES)) assert coordinator.data.active.guest_name == "Override Guest" # Current (pure backend mirror) is unaffected by the override. assert coordinator.data.current.guest_name is None async def test_start_override_sets_override_active_flag(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES)) assert coordinator.data.override_active is True async def test_start_override_cancels_mode_sync(coordinator: RoomCalCoordinator): cancel1, cancel2 = MagicMock(), MagicMock() coordinator._mode_sync_handles = [cancel1, cancel2] coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES)) cancel1.assert_called_once() cancel2.assert_called_once() assert coordinator._mode_sync_handles == [] async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinator): mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coordinator._room_state_switch = mock_switch active_override = ReservationData( guest_name=_OVERRIDE_RES.guest_name, door_code=_OVERRIDE_RES.door_code, check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), ) with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), ): await coordinator.async_start_override(OverrideData(active=active_override)) coordinator.cancel_mode_sync() mock_switch.async_turn_on.assert_awaited_once() async def test_start_override_does_not_turn_on_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator): mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coordinator._room_state_switch = mock_switch with patch.object(coordinator, "_is_mode_sync_on", return_value=False): await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES)) mock_switch.async_turn_on.assert_not_awaited() async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator): coordinator._room_state_switch = MagicMock() coordinator._room_state_switch.async_turn_on = AsyncMock() active_wifi_override = ReservationData( guest_name=_WIFI_OVERRIDE.guest_name, wifi_ssid=_WIFI_OVERRIDE.wifi_ssid, wifi_key=_WIFI_OVERRIDE.wifi_key, check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), ) with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi, ): await coordinator.async_start_override(OverrideData(active=active_wifi_override)) coordinator.cancel_mode_sync() mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret") async def test_start_override_skips_wifi_when_mode_sync_off(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None with ( patch.object(coordinator, "_is_mode_sync_on", return_value=False), patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi, ): await coordinator.async_start_override(OverrideData(active=_WIFI_OVERRIDE)) mock_wifi.assert_not_awaited() async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None no_ssid = ReservationData(wifi_ssid=None, wifi_key="secret") with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi: await coordinator.async_start_override(OverrideData(active=no_ssid)) mock_wifi.assert_not_awaited() async def test_start_override_next_always_mirrors_store(coordinator: RoomCalCoordinator): """There's no such thing as an override-owned Next — Next always mirrors the store's own feed, regardless of any override on active.""" await coordinator.store.async_set(STORE_NEXT, SAMPLE) coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES)) assert coordinator.data.next.guest_name == "Alice" async def test_start_override_applies_defaults_to_current(hass: HomeAssistant): entry = MockConfigEntry( domain=DOMAIN, data={}, options={ CONF_SYNC_MODE: SYNC_MODE_MANUAL, CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID", "check_out_time": "11:00:00"}, }, ) entry.add_to_hass(hass) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() coord._guest_mode_switch = None partial_override = ReservationData(guest_name="Override Guest") await coord.async_start_override(OverrideData(active=partial_override)) assert coord.data.active.guest_name == "Override Guest" assert coord.data.active.wifi_ssid == "DefaultSSID" assert coord.data.active.check_out_time == time(11, 0) async def test_start_override_defaults_do_not_overwrite_explicit_fields(hass: HomeAssistant): entry = MockConfigEntry( domain=DOMAIN, data={}, options={ CONF_SYNC_MODE: SYNC_MODE_MANUAL, CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"}, }, ) entry.add_to_hass(hass) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() coord._guest_mode_switch = None explicit_override = ReservationData(guest_name="Override Guest", wifi_ssid="ExplicitSSID") await coord.async_start_override(OverrideData(active=explicit_override)) assert coord.data.active.wifi_ssid == "ExplicitSSID" async def test_refresh_during_override_applies_defaults_to_current(hass: HomeAssistant): entry = MockConfigEntry( domain=DOMAIN, data={}, options={ CONF_SYNC_MODE: SYNC_MODE_MANUAL, CONF_RESERVATION_DEFAULTS: {"door_code": "9999"}, }, ) entry.add_to_hass(hass) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() coord._guest_mode_switch = None partial_override = ReservationData(guest_name="Override Guest") await coord.async_start_override(OverrideData(active=partial_override)) await coord.async_refresh() assert coord.data.active.guest_name == "Override Guest" assert coord.data.active.door_code == "9999" async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant): entry = MockConfigEntry( domain=DOMAIN, data={}, options={ CONF_SYNC_MODE: SYNC_MODE_MANUAL, CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"}, }, ) entry.add_to_hass(hass) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() coord._guest_mode_switch = None await coord.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest")) await coord.async_start_override(OverrideData(active=_OVERRIDE_RES)) assert coord.data.next.guest_name == "Next Guest" assert coord.data.next.wifi_ssid == "DefaultSSID" async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None coordinator._override = OverrideData(active=_OVERRIDE_RES) await coordinator.async_end_override() assert coordinator.data.override_active is False async def test_end_override_restores_store_current(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) coordinator._room_state_switch = None coordinator._override = OverrideData(active=_OVERRIDE_RES) await coordinator.async_end_override() assert coordinator.data.active.guest_name == "Alice" async def test_start_override_with_end_at_schedules_and_ends_override(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None override = OverrideData(active=ReservationData(guest_name="Timed Guest")) with ( patch.object(coordinator, "_is_mode_sync_on", return_value=False), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), ): await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0)) scheduled_handle = coordinator._override_end_handle assert scheduled_handle is not None assert coordinator.data.override_active is True # Simulate the scheduled call_later firing, then cancel the real (never-fired) handle # so it doesn't leak into the next test. await coordinator._async_do_override_timeout() scheduled_handle() assert coordinator._override_end_handle is None assert coordinator.data.override_active is False async def test_stale_override_timeout_does_not_end_a_replacing_override(coordinator: RoomCalCoordinator): """A timer from an override that was already manually ended/replaced must not end whatever override is active by the time it fires.""" coordinator._room_state_switch = None override = OverrideData(active=ReservationData(guest_name="Timed Guest")) with ( patch.object(coordinator, "_is_mode_sync_on", return_value=False), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), ): await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0)) stale_target = coordinator._override_end_target await coordinator.async_end_override() assert coordinator._override is None new_override = OverrideData(active=ReservationData(guest_name="New Guest")) with patch.object(coordinator, "_is_mode_sync_on", return_value=False): await coordinator.async_start_override(new_override) # no end_at — no new timer # Simulate the original (stale) timer's callback firing late, after the replacement started. coordinator._override_end_target = stale_target await coordinator._async_do_override_timeout() assert coordinator._override is new_override assert coordinator.data.override_active is True async def test_start_override_empty_current_triggers_checkout_not_checkin( hass: HomeAssistant, coordinator: RoomCalCoordinator ): """Overriding Current to empty while a real guest is checked in must run checkout actions (SSID disable, guest-mode off) — not try to check in an empty record.""" checked_in_guest = ReservationData( guest_name="Real Guest", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), wifi_ssid="GuestNet", wifi_key="secret", ) await coordinator.store.async_set(STORE_CURRENT, checked_in_guest) mock_switch = MagicMock() mock_switch.async_turn_off = AsyncMock() mock_switch.async_turn_on = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid, ): await coordinator.async_start_override(OverrideData(active=ReservationData())) coordinator.cancel_mode_sync() mock_ssid.assert_awaited_once_with(hass, "GuestNet", False) mock_switch.async_turn_off.assert_awaited_once() mock_switch.async_turn_on.assert_not_awaited() assert coordinator.data.override_active is True async def test_override_active_schedules_no_mode_sync_handles( hass: HomeAssistant, coordinator: RoomCalCoordinator ): """While an override is active, no Store-driven check-in/check-out handles are scheduled — an override parks Active until it's ended manually or its own end_at timer fires. Next's check-in time elapsing (Store empty -> occupied) must not overwrite the override.""" override_guest = ReservationData( guest_name="Override Guest", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), ) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() mock_switch.async_turn_off = AsyncMock() coordinator._room_state_switch = mock_switch await coordinator.store.async_set(STORE_NEXT, ReservationData( guest_name="Next Guest", check_in_date=date(2026, 7, 1), check_in_time=time(13, 0), )) with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), ): await coordinator.async_start_override(OverrideData(active=override_guest)) assert coordinator._mode_sync_handles == [] assert coordinator.data.override_active is True assert coordinator.data.active.guest_name == "Override Guest" async def test_ending_override_reschedules_mode_sync_from_store( hass: HomeAssistant, coordinator: RoomCalCoordinator ): """Ending an override re-arms the normal check-in/check-out handles from whatever the Store currently holds — the same reconcile-and-reschedule path used at startup.""" override_guest = ReservationData( guest_name="Override Guest", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), ) coordinator._room_state_switch = None await coordinator.store.async_set(STORE_NEXT, ReservationData( guest_name="Next Guest", check_in_date=date(2026, 7, 1), check_in_time=time(15, 0), )) with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), ): await coordinator.async_start_override(OverrideData(active=override_guest)) assert coordinator._mode_sync_handles == [] await coordinator.async_end_override() assert coordinator.data.override_active is False # The check-in handle for Next Guest (15:00, still in the future relative to # _AT_CHECK_IN) is re-armed now that the override no longer owns Active. assert len(coordinator._mode_sync_handles) >= 1 coordinator.cancel_mode_sync() # --------------------------------------------------------------------------- # Thin updates during override # --------------------------------------------------------------------------- async def test_thin_update_during_override_updates_override_not_store(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() coordinator._override = OverrideData(active=_OVERRIDE_RES) await coordinator.async_apply_update({"door_code": "9999"}) assert coordinator.data.active.door_code == "9999" # Store is unchanged assert coordinator.store.get(STORE_CURRENT).door_code == "0472" async def test_thin_update_during_override_data_still_shows_override_active(coordinator: RoomCalCoordinator): await coordinator.async_refresh() coordinator._override = OverrideData(active=_OVERRIDE_RES) await coordinator.async_apply_update({"door_code": "9999"}) assert coordinator.data.override_active is True async def test_thin_update_with_no_override_running_starts_one(coordinator: RoomCalCoordinator): """Patching with no override active must snapshot `active` as an override first, so the patch survives the next backend poll instead of being silently overwritten.""" await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() assert coordinator.data.override_active is False await coordinator.async_apply_update({"guest_name": "Patched Guest"}) assert coordinator.data.override_active is True assert coordinator.data.active.guest_name == "Patched Guest" # Store's own current is untouched by the patch. assert coordinator.store.get(STORE_CURRENT).guest_name == "Alice" # --------------------------------------------------------------------------- # Backend fetch during override preserves override display # --------------------------------------------------------------------------- async def test_backend_fetch_during_override_updates_store_but_preserves_override(hass: HomeAssistant): entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() coord._override = OverrideData(active=_OVERRIDE_RES) backend_data = ReservationData(guest_name="Backend Alice") with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend: MockBackend.return_value.async_fetch = AsyncMock(return_value=(backend_data, None)) await coord.async_refresh() # Store was written by the backend assert coord.store.get(STORE_CURRENT).guest_name == "Backend Alice" # But displayed active still shows the override; current mirrors the fresh backend write. assert coord.data.active.guest_name == "Override Guest" assert coord.data.current.guest_name == "Backend Alice" assert coord.data.override_active is True # --------------------------------------------------------------------------- # async_reconcile_from_store (shared helper) # --------------------------------------------------------------------------- _ACTIVE = ReservationData( guest_name="Active Guest", check_in_date=date(2026, 6, 20), check_in_time=time(14, 0), check_out_date=date(2026, 6, 25), check_out_time=time(12, 0), ) _ELAPSED = ReservationData( guest_name="Past Guest", check_in_date=date(2026, 6, 1), check_in_time=time(14, 0), check_out_date=date(2026, 6, 5), check_out_time=time(12, 0), ) _FUTURE = ReservationData( guest_name="Future Guest", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), ) # Freeze time at 2026-06-22 12:00 local (naive) _RECONCILE_NOW = MagicMock() _RECONCILE_NOW.replace.return_value = datetime(2026, 6, 22, 12, 0, 0) async def test_reconcile_active_current_turns_on_guest_mode(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, _ACTIVE) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() mock_switch.async_turn_off = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW), ): await coordinator.async_reconcile_from_store() coordinator.cancel_mode_sync() mock_switch.async_turn_on.assert_awaited_once() mock_switch.async_turn_off.assert_not_awaited() async def test_reconcile_elapsed_current_clears_store_and_turns_off_guest_mode(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, _ELAPSED) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() mock_switch.async_turn_off = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW), ): await coordinator.async_reconcile_from_store() assert coordinator.store.get(STORE_CURRENT) is None mock_switch.async_turn_off.assert_awaited_once() async def test_reconcile_promotes_next_when_check_in_elapsed(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_NEXT, _ACTIVE) with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW), ): await coordinator.async_reconcile_from_store() coordinator.cancel_mode_sync() assert coordinator.store.get(STORE_CURRENT).guest_name == "Active Guest" async def test_reconcile_turns_off_guest_mode_when_store_already_cleared(coordinator: RoomCalCoordinator): """Store was cleared (e.g. checkout write ran) but guest-mode-off action never ran.""" # No current reservation in store — it was already cleared before HA was disabled. mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() mock_switch.async_turn_off = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW), ): await coordinator.async_reconcile_from_store() coordinator.cancel_mode_sync() mock_switch.async_turn_off.assert_awaited_once() mock_switch.async_turn_on.assert_not_awaited() async def test_reconcile_does_not_touch_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, _ACTIVE) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() mock_switch.async_turn_off = AsyncMock() coordinator._room_state_switch = mock_switch with ( patch.object(coordinator, "_is_mode_sync_on", return_value=False), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW), ): await coordinator.async_reconcile_from_store() mock_switch.async_turn_on.assert_not_awaited() mock_switch.async_turn_off.assert_not_awaited() # --------------------------------------------------------------------------- # _diff_with_unifi_check # --------------------------------------------------------------------------- _ACTIVE_WIFI_RES = ReservationData( check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), wifi_ssid="GuestNet", wifi_key="correct-key", ) _DURING_WINDOW = datetime(2026, 7, 3, 12, 0, 0) def _unifi_diff(coordinator, old, new, now, *, enabled, key): with ( patch.object(coordinator, "_is_use_unifi_on", return_value=True), patch("custom_components.room_cal_ha.wifi.get_wlan_state", return_value=(enabled, key)), ): return coordinator._diff_with_unifi_check(old, new, now) async def test_unifi_diff_ssid_disabled_adds_checkin_needed(coordinator): diff = _unifi_diff(coordinator, None, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="correct-key") assert diff.checkin_needed async def test_unifi_diff_key_mismatch_adds_wifi_key_changed(coordinator): diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="wrong-key") assert diff.wifi_key_changed async def test_unifi_diff_ssid_disabled_and_key_wrong_sets_both(coordinator): diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="wrong-key") assert diff.checkin_needed assert diff.wifi_key_changed async def test_unifi_diff_state_matches_no_extra_flags(coordinator): diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key") assert not diff.has_changes async def test_unifi_diff_skipped_when_use_unifi_off(coordinator): with ( patch.object(coordinator, "_is_use_unifi_on", return_value=False), patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get, ): diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW) mock_get.assert_not_called() assert not diff.has_changes # base diff: same record, no changes; unifi check skipped async def test_unifi_diff_skipped_when_window_not_active(coordinator): # Unchanged record (no occupancy transition) isolates the UniFi drift-check gate itself: # even with use_unifi on, no wall-clock-active window means no drift check is made. before_window = datetime(2026, 6, 30, 12, 0, 0) with ( patch.object(coordinator, "_is_use_unifi_on", return_value=True), patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get, ): diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, before_window) mock_get.assert_not_called() assert not diff.has_changes async def test_unifi_diff_skipped_when_hub_unavailable(coordinator): diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=None, key=None) assert not diff.has_changes async def test_unifi_diff_key_check_skipped_when_actual_key_none(coordinator): # Controller doesn't expose passphrase — only enabled state checked. diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key=None) assert not diff.has_changes async def test_unifi_diff_preserves_original_diff_flags(coordinator): # Even when UniFi state is clean, original checkin_needed from the base diff is kept. old_inactive = ReservationData(wifi_ssid="GuestNet", wifi_key="correct-key") # no dates → not active diff = _unifi_diff(coordinator, old_inactive, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key") assert diff.checkin_needed # from reservation_diff: old not active, new is active