|
|
@@ -0,0 +1,303 @@
|
|
|
+"""Unit tests for RoomCalApiBackend."""
|
|
|
+from contextlib import asynccontextmanager
|
|
|
+from datetime import date, datetime, timedelta, timezone
|
|
|
+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):
|
|
|
+ return {
|
|
|
+ "guest_name": guest,
|
|
|
+ "checkin_date": str(checkin),
|
|
|
+ "checkout_date": str(checkout),
|
|
|
+ "wifi_key": "wifikey",
|
|
|
+ "door_code": "1234",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# _parse_reservation
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+def test_parse_reservation_maps_fields():
|
|
|
+ raw = {
|
|
|
+ "guest_name": "Alice",
|
|
|
+ "checkin_date": "2026-07-01",
|
|
|
+ "checkout_date": "2026-07-05",
|
|
|
+ "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_out_date == date(2026, 7, 5)
|
|
|
+ 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_out_date is None
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# async_fetch — reservation classification
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+async def test_fetch_current_today_no_next(hass: HomeAssistant):
|
|
|
+ today = date.today()
|
|
|
+ tomorrow = today + timedelta(days=1)
|
|
|
+ response = {str(today): _reservation_slot("Alice", today, 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):
|
|
|
+ today = date.today()
|
|
|
+ tomorrow = today + timedelta(days=1)
|
|
|
+ future = today + timedelta(days=5)
|
|
|
+ future_end = today + timedelta(days=8)
|
|
|
+ response = {
|
|
|
+ str(today): _reservation_slot("Alice", today, 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 reservation spanning multiple days should produce one current, not also be treated as next."""
|
|
|
+ today = date.today()
|
|
|
+ checkout = today + timedelta(days=3)
|
|
|
+ slot = _reservation_slot("Alice", today, 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_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()
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# async_fetch_status
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+async def test_fetch_status_ok(hass: HomeAssistant):
|
|
|
+ ts = "2026-06-22T10:00:00+00:00"
|
|
|
+ body = {
|
|
|
+ "status": "ok",
|
|
|
+ "checks": {
|
|
|
+ "caldav_connection": {"ok": True},
|
|
|
+ "calendar": {"ok": True},
|
|
|
+ "credentials": {},
|
|
|
+ },
|
|
|
+ "checked_at": ts,
|
|
|
+ }
|
|
|
+ session = _mock_session(200, body)
|
|
|
+ backend = RoomCalApiBackend(hass, _make_entry())
|
|
|
+
|
|
|
+ with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
|
|
|
+ status = await backend.async_fetch_status()
|
|
|
+
|
|
|
+ assert status.status == "ok"
|
|
|
+ assert status.checks["caldav_connection"]["ok"] is True
|
|
|
+ assert status.checked_at == datetime.fromisoformat(ts)
|
|
|
+ assert status.stale is False
|
|
|
+
|
|
|
+
|
|
|
+async def test_fetch_status_stale_on_non_200(hass: HomeAssistant):
|
|
|
+ body = {"status": "error", "checks": {}, "checked_at": "2026-06-22T10:00:00+00:00"}
|
|
|
+ session = _mock_session(503, body)
|
|
|
+ # 503 is not in 401/403 so it won't raise BackendAuthError; raise_for_status is NOT called in async_fetch_status
|
|
|
+ backend = RoomCalApiBackend(hass, _make_entry())
|
|
|
+
|
|
|
+ with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
|
|
|
+ status = await backend.async_fetch_status()
|
|
|
+
|
|
|
+ assert status.status == "error"
|
|
|
+ assert status.stale is True
|
|
|
+
|
|
|
+
|
|
|
+async def test_fetch_status_401_returns_auth_error_status(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):
|
|
|
+ status = await backend.async_fetch_status()
|
|
|
+
|
|
|
+ assert status.status == "auth_error"
|
|
|
+
|
|
|
+
|
|
|
+async def test_fetch_status_network_error_returns_unknown(hass: HomeAssistant):
|
|
|
+ session = _mock_session_raises(Exception("timeout"))
|
|
|
+ backend = RoomCalApiBackend(hass, _make_entry())
|
|
|
+
|
|
|
+ with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
|
|
|
+ status = await backend.async_fetch_status()
|
|
|
+
|
|
|
+ assert status.status == "unknown"
|
|
|
+
|
|
|
+
|
|
|
+async def test_fetch_status_bad_checked_at_doesnt_crash(hass: HomeAssistant):
|
|
|
+ body = {"status": "ok", "checks": {}, "checked_at": "not-a-date"}
|
|
|
+ session = _mock_session(200, body)
|
|
|
+ backend = RoomCalApiBackend(hass, _make_entry())
|
|
|
+
|
|
|
+ with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
|
|
|
+ status = await backend.async_fetch_status()
|
|
|
+
|
|
|
+ assert status.status == "ok"
|
|
|
+ assert status.checked_at is None
|