"""Unit tests for RoomCalApiBackend.""" from contextlib import asynccontextmanager from datetime import date, time, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.core import HomeAssistant from custom_components.room_cal_ha.backends import BackendAuthError, BackendConnectError from custom_components.room_cal_ha.backends.api import RoomCalApiBackend, _parse_reservation from custom_components.room_cal_ha.const import CONF_API_BASE_URL, CONF_API_TOKEN, CONF_SYNC_MODE, SYNC_MODE_API from custom_components.room_cal_ha.models import ReservationData # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_entry(base_url="https://api.example.com", token="test-token"): entry = MagicMock() entry.options = {CONF_API_BASE_URL: base_url, CONF_SYNC_MODE: SYNC_MODE_API} entry.data = {CONF_API_TOKEN: token} return entry def _mock_session(status: int, json_data=None): """Return a mock aiohttp session that yields one response.""" resp = MagicMock() resp.status = status resp.json = AsyncMock(return_value=json_data) if status >= 400 and status not in (401, 403): resp.raise_for_status.side_effect = Exception(f"HTTP {status}") @asynccontextmanager async def _get(*args, **kwargs): yield resp session = MagicMock() session.get = _get return session def _mock_session_raises(exc): """Return a session whose .get() call raises immediately.""" session = MagicMock() session.get = MagicMock(side_effect=exc) return session def _reservation_slot(guest, checkin, checkout, checkin_time="14:00", checkout_time="11:00"): return { "guest_name": guest, "checkin_date": str(checkin), "checkin_time": checkin_time, "checkout_date": str(checkout), "checkout_time": checkout_time, "wifi_ssid": "GuestNet", "wifi_key": "wifikey", "door_code": "1234", } # --------------------------------------------------------------------------- # _parse_reservation # --------------------------------------------------------------------------- def test_parse_reservation_maps_fields(): raw = { "guest_name": "Alice", "checkin_date": "2026-07-01", "checkin_time": "14:00", "checkout_date": "2026-07-05", "checkout_time": "11:00", "wifi_ssid": "GuestNet", "wifi_key": "secret", "door_code": "4567", } res = _parse_reservation(raw) assert isinstance(res, ReservationData) assert res.guest_name == "Alice" assert res.check_in_date == date(2026, 7, 1) assert res.check_in_time == time(14, 0) assert res.check_out_date == date(2026, 7, 5) assert res.check_out_time == time(11, 0) assert res.wifi_ssid == "GuestNet" assert res.wifi_key == "secret" assert res.door_code == "4567" def test_parse_reservation_none_returns_none(): assert _parse_reservation(None) is None def test_parse_reservation_missing_dates_gives_none_fields(): res = _parse_reservation({"guest_name": "Bob"}) assert res.guest_name == "Bob" assert res.check_in_date is None assert res.check_in_time is None assert res.check_out_date is None assert res.check_out_time is None # --------------------------------------------------------------------------- # async_fetch — reservation classification # --------------------------------------------------------------------------- async def test_fetch_current_today_no_next(hass: HomeAssistant): # checkin yesterday at 14:00, checkout tomorrow at 11:00 — window always active today = date.today() yesterday = today - timedelta(days=1) tomorrow = today + timedelta(days=1) response = {str(today): _reservation_slot("Alice", yesterday, tomorrow)} session = _mock_session(200, response) entry = _make_entry() backend = RoomCalApiBackend(hass, entry) with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session): current, nxt = await backend.async_fetch() assert current is not None assert current.guest_name == "Alice" assert nxt is None async def test_fetch_no_current_next_upcoming(hass: HomeAssistant): today = date.today() future = today + timedelta(days=5) future_end = today + timedelta(days=8) response = { str(today): None, str(future): _reservation_slot("Bob", future, future_end), } session = _mock_session(200, response) backend = RoomCalApiBackend(hass, _make_entry()) with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session): current, nxt = await backend.async_fetch() assert current is None assert nxt is not None assert nxt.guest_name == "Bob" async def test_fetch_both_current_and_next(hass: HomeAssistant): # Alice: window always active (checked in yesterday, out tomorrow) # Bob: future (5 days from now) today = date.today() yesterday = today - timedelta(days=1) tomorrow = today + timedelta(days=1) future = today + timedelta(days=5) future_end = today + timedelta(days=8) response = { str(today): _reservation_slot("Alice", yesterday, tomorrow), str(future): _reservation_slot("Bob", future, future_end), } session = _mock_session(200, response) backend = RoomCalApiBackend(hass, _make_entry()) with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session): current, nxt = await backend.async_fetch() assert current.guest_name == "Alice" assert nxt.guest_name == "Bob" async def test_fetch_multiday_current_not_duplicated_as_next(hass: HomeAssistant): """A multi-day stay appearing on multiple date keys should produce one current, not also next.""" # checkin yesterday, checkout in 3 days — window always active regardless of test time today = date.today() yesterday = today - timedelta(days=1) checkout = today + timedelta(days=3) slot = _reservation_slot("Alice", yesterday, checkout) # Same reservation appears on each day it spans response = { str(today): slot, str(today + timedelta(days=1)): slot, str(today + timedelta(days=2)): slot, } session = _mock_session(200, response) backend = RoomCalApiBackend(hass, _make_entry()) with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session): current, nxt = await backend.async_fetch() assert current.guest_name == "Alice" assert nxt is None async def test_fetch_current_not_promoted_before_checkin_time(hass: HomeAssistant): """Next guest's slot should not become current just because their check-in DATE is today.""" # Guest A: checked in 2 days ago, checks out today at 23:59 — window active # Guest B: checks in today at 14:00 — should be next, not current # This covers the midnight-transition bug where a date-key lookup would wrongly # promote Guest B to current at midnight of their check-in date. from datetime import datetime as _dt, time as _time today = date.today() two_days_ago = today - timedelta(days=2) tomorrow = today + timedelta(days=1) response = { str(today): _reservation_slot("Alice", two_days_ago, today, checkout_time="23:59"), str(tomorrow): _reservation_slot("Bob", today, tomorrow, checkin_time="14:00"), } # Pin now to 09:00 — before Alice's checkout AND before Bob's check-in fake_now = _dt.combine(today, _time(9, 0)) session = _mock_session(200, response) backend = RoomCalApiBackend(hass, _make_entry()) with ( patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session), patch("custom_components.room_cal_ha.backends.api.datetime") as mock_dt, ): mock_dt.now.return_value = fake_now mock_dt.combine = _dt.combine current, nxt = await backend.async_fetch() assert current is not None assert current.guest_name == "Alice" assert nxt is not None assert nxt.guest_name == "Bob" async def test_fetch_empty_response(hass: HomeAssistant): session = _mock_session(200, {}) backend = RoomCalApiBackend(hass, _make_entry()) with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session): current, nxt = await backend.async_fetch() assert current is None assert nxt is None # --------------------------------------------------------------------------- # async_fetch — error handling # --------------------------------------------------------------------------- async def test_fetch_401_raises_backend_auth_error(hass: HomeAssistant): session = _mock_session(401, {}) backend = RoomCalApiBackend(hass, _make_entry()) with ( patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session), pytest.raises(BackendAuthError), ): await backend.async_fetch() async def test_fetch_403_raises_backend_auth_error(hass: HomeAssistant): session = _mock_session(403, {}) backend = RoomCalApiBackend(hass, _make_entry()) with ( patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session), pytest.raises(BackendAuthError), ): await backend.async_fetch() async def test_fetch_network_error_raises_backend_connect_error(hass: HomeAssistant): session = _mock_session_raises(Exception("connection refused")) backend = RoomCalApiBackend(hass, _make_entry()) with ( patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session), pytest.raises(BackendConnectError), ): await backend.async_fetch() async def test_fetch_500_raises_backend_connect_error(hass: HomeAssistant): session = _mock_session(500, {}) backend = RoomCalApiBackend(hass, _make_entry()) with ( patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session), pytest.raises(BackendConnectError), ): await backend.async_fetch()