from datetime import date, datetime, time, timezone 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_API_TOKEN, CONF_CALDAV_URL, CONF_CALENDAR_NAME, 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 ApiStatusData, 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 assert coordinator.data.api_status 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 # --------------------------------------------------------------------------- SAMPLE_STATUS = ApiStatusData( status="ok", checks={"caldav_connection": {"ok": True}, "calendar": {"ok": True}}, ) async def test_api_fetch_writes_store_and_status(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)) instance.async_fetch_status = AsyncMock(return_value=SAMPLE_STATUS) instance.async_fetch_services = AsyncMock(return_value={}) await coord.async_refresh() assert coord.data.current.guest_name == "Alice" assert coord.data.api_status.status == "ok" assert coord.data.current_last_refresh is not None async def test_api_connect_error_preserves_store_and_still_fetches_status(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) degraded = ApiStatusData(status="error") with patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend: instance = MockBackend.return_value instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout")) instance.async_fetch_status = AsyncMock(return_value=degraded) instance.async_fetch_services = AsyncMock(return_value={}) await coord.async_refresh() assert coord.data.current.guest_name == "Alice" assert coord.data.current_last_refresh is None assert coord.data.api_status.status == "error" 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) instance.async_fetch_status = AsyncMock(return_value=ApiStatusData(status="auth_error")) instance.async_fetch_services = AsyncMock(return_value={}) 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 # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- _UTC = timezone.utc 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): """Call _reservation_window_state with dt_util.now() frozen to dt_str (local naive → UTC).""" frozen = datetime.fromisoformat(dt_str).replace(tzinfo=_UTC) 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_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", ) 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._guest_mode_switch = mock_switch 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_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._guest_mode_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._guest_mode_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_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._guest_mode_switch = mock_switch with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")): await coordinator._async_do_check_in() mock_switch.async_turn_on.assert_awaited_once()