from datetime import date, datetime, time 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, CONF_ZLOCK_ENTRIES, CONF_ZWAVE_SLOT, 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(STORE_CURRENT, {"wifi_ssid": "GuestNet", "door_code": "9999"}) assert coordinator.data.current.wifi_ssid == "GuestNet" assert coordinator.data.current.door_code == "9999" assert coordinator.data.current.guest_name == "Alice" assert coordinator.data.current.wifi_key == "stone-angel-market" async def test_thin_update_updates_last_refresh(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() assert coordinator.data.current_last_refresh is None await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "1234"}) assert coordinator.data.current_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(STORE_CURRENT, {"door_code": "9999"}) assert coordinator.data.current_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(STORE_CURRENT, {"door_code": "9999"}) ts = coordinator.data.current_last_updated # Second update with the same value must not advance last_updated await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"}) assert coordinator.data.current_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.current_last_updated is None await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override"))) assert coordinator.data.current_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(current=ReservationData(guest_name="Override"))) ts_start = coordinator.data.current_last_updated await coordinator.store.async_set(STORE_CURRENT, SAMPLE) coordinator._override = OverrideData(current=ReservationData(guest_name="Override")) await coordinator.async_end_override() assert coordinator.data.current_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) 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_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.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.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_current_in_data(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES)) assert coordinator.data.current.guest_name == "Override Guest" async def test_start_override_sets_override_active_flag(coordinator: RoomCalCoordinator): coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(current=_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(current=_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 with patch.object(coordinator, "_is_mode_sync_on", return_value=True): await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES)) 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(current=_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() with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi, ): await coordinator.async_start_override(OverrideData(current=_WIFI_OVERRIDE)) 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(current=_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(current=no_ssid)) mock_wifi.assert_not_awaited() async def test_start_override_uses_store_next_when_override_next_is_none(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_NEXT, SAMPLE) coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES)) assert coordinator.data.next.guest_name == "Alice" async def test_start_override_uses_override_next_when_provided(coordinator: RoomCalCoordinator): override_next = ReservationData(guest_name="Override Next") coordinator._room_state_switch = None await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES, next=override_next)) assert coordinator.data.next.guest_name == "Override Next" 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(current=partial_override)) assert coord.data.current.guest_name == "Override Guest" assert coord.data.current.wifi_ssid == "DefaultSSID" assert coord.data.current.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(current=explicit_override)) assert coord.data.current.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(current=partial_override)) await coord.async_refresh() assert coord.data.current.guest_name == "Override Guest" assert coord.data.current.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(current=_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(current=_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(current=_OVERRIDE_RES) await coordinator.async_end_override() assert coordinator.data.current.guest_name == "Alice" # --------------------------------------------------------------------------- # Thin updates during override # --------------------------------------------------------------------------- async def test_thin_update_current_during_override_updates_override_not_store(coordinator: RoomCalCoordinator): await coordinator.store.async_set(STORE_CURRENT, SAMPLE) await coordinator.async_refresh() coordinator._override = OverrideData(current=_OVERRIDE_RES) await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"}) assert coordinator.data.current.door_code == "9999" # Store is unchanged assert coordinator.store.get(STORE_CURRENT).door_code == "0472" async def test_thin_update_current_during_override_data_still_shows_override_active(coordinator: RoomCalCoordinator): await coordinator.async_refresh() coordinator._override = OverrideData(current=_OVERRIDE_RES) await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"}) assert coordinator.data.override_active is True async def test_thin_update_next_during_override_falls_through_to_store(coordinator: RoomCalCoordinator): coordinator._override = OverrideData(current=_OVERRIDE_RES) # override.next is None await coordinator.async_refresh() await coordinator.async_apply_update(STORE_NEXT, {"guest_name": "Next Guest"}) assert coordinator.store.get(STORE_NEXT).guest_name == "Next Guest" assert coordinator.data.override_active is True assert coordinator.data.current.guest_name == "Override Guest" # --------------------------------------------------------------------------- # 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(current=_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 data still shows the override assert coord.data.current.guest_name == "Override Guest" 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): 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(None, _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 def _make_zlock_entry(hass, lock_entity_ids: list[str]) -> MockConfigEntry: zlock = MockConfigEntry(domain="zlock_slots", data={"lock_entity_ids": lock_entity_ids}) zlock.add_to_hass(hass) return zlock @pytest.fixture async def coordinator_with_locks(hass: HomeAssistant): zlock = _make_zlock_entry(hass, ["lock.front_door"]) entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() return coord 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 # --------------------------------------------------------------------------- # Check-in Z-Wave lock behavior # --------------------------------------------------------------------------- _ZWAVE_RES = 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), door_code="7890", ) async def test_check_in_sets_lock_codes(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator): await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coordinator_with_locks._room_state_switch = mock_switch with ( patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True), patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call, ): await coordinator_with_locks._async_do_check_in() coordinator_with_locks.cancel_mode_sync() mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.front_door", "slot": 5, "code": "7890"}) mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": True}) mock_switch.async_turn_on.assert_awaited_once() async def test_check_in_sets_codes_on_all_locks(hass: HomeAssistant): zlock = _make_zlock_entry(hass, ["lock.front", "lock.back"]) entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5}) coord = RoomCalCoordinator(hass, entry) await coord.async_setup() await coord.store.async_set(STORE_NEXT, _ZWAVE_RES) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coord._room_state_switch = mock_switch with ( patch.object(coord, "_is_mode_sync_on", return_value=True), patch.object(coord, "_is_use_zwave_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch.object(coord, "_async_call_service", new_callable=AsyncMock) as mock_call, ): await coord._async_do_check_in() coord.cancel_mode_sync() mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.front", "slot": 5, "code": "7890"}) mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.back", "slot": 5, "code": "7890"}) mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front", "slot": 5, "enabled": True}) mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.back", "slot": 5, "enabled": True}) async def test_check_in_skips_lock_codes_when_door_code_none(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator): no_code = _ZWAVE_RES.__class__( guest_name="Bob", check_in_date=_ZWAVE_RES.check_in_date, check_in_time=_ZWAVE_RES.check_in_time, check_out_date=_ZWAVE_RES.check_out_date, check_out_time=_ZWAVE_RES.check_out_time, door_code=None, ) await coordinator_with_locks.store.async_set(STORE_NEXT, no_code) coordinator_with_locks._room_state_switch = None with ( patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True), patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call, ): await coordinator_with_locks._async_do_check_in() coordinator_with_locks.cancel_mode_sync() assert not any( c.args[:2] == ("zlock_slots", "set_code") for c in mock_call.await_args_list ) async def test_check_in_skips_lock_codes_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator): await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES) coordinator_with_locks._room_state_switch = None with ( patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True), patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call, ): await coordinator_with_locks._async_do_check_in() coordinator_with_locks.cancel_mode_sync() mock_call.assert_not_awaited() async def test_check_in_skips_lock_codes_when_no_locks_configured(hass: HomeAssistant, coordinator: RoomCalCoordinator): # coordinator fixture has no CONF_ZLOCK_ENTRIES in options await coordinator.store.async_set(STORE_NEXT, _ZWAVE_RES) coordinator._room_state_switch = None with ( patch.object(coordinator, "_is_mode_sync_on", return_value=True), patch.object(coordinator, "_is_use_zwave_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch.object(coordinator, "_async_call_service", new_callable=AsyncMock) as mock_call, ): await coordinator._async_do_check_in() coordinator.cancel_mode_sync() mock_call.assert_not_awaited() async def test_check_in_lock_error_still_activates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator): await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES) mock_switch = MagicMock() mock_switch.async_turn_on = AsyncMock() coordinator_with_locks._room_state_switch = mock_switch with ( patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True), patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True), patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN), patch.object(coordinator_with_locks, "_async_call_service", side_effect=HomeAssistantError("lock offline")), ): await coordinator_with_locks._async_do_check_in() coordinator_with_locks.cancel_mode_sync() mock_switch.async_turn_on.assert_awaited_once() # --------------------------------------------------------------------------- # Check-out Z-Wave lock behavior # --------------------------------------------------------------------------- async def test_check_out_disables_lock_slot(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator): await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES) mock_switch = MagicMock() mock_switch.async_turn_off = AsyncMock() coordinator_with_locks._room_state_switch = mock_switch with ( patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True), patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True), patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call, ): await coordinator_with_locks._async_do_check_out() mock_call.assert_awaited_once_with("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": False}) mock_switch.async_turn_off.assert_awaited_once() async def test_check_out_skips_lock_slot_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator): await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES) coordinator_with_locks._room_state_switch = None with ( patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True), patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False), patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call, ): await coordinator_with_locks._async_do_check_out() mock_call.assert_not_awaited() async def test_check_out_lock_error_still_deactivates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator): await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES) mock_switch = MagicMock() mock_switch.async_turn_off = AsyncMock() coordinator_with_locks._room_state_switch = mock_switch with ( patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True), patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True), patch.object(coordinator_with_locks, "_async_call_service", side_effect=HomeAssistantError("lock offline")), ): await coordinator_with_locks._async_do_check_out() mock_switch.async_turn_off.assert_awaited_once()