Przeglądaj źródła

Add tests for UniFi sensor, check-in WiFi behavior, and reservation window state

ZiB 4 miesięcy temu
rodzic
commit
2c58a9d2de
5 zmienionych plików z 299 dodań i 3 usunięć
  1. 1 0
      pyproject.toml
  2. 40 1
      tests/test_binary_sensor.py
  3. 100 2
      tests/test_coordinator.py
  4. 95 0
      tests/test_wifi.py
  5. 63 0
      uv.lock

+ 1 - 0
pyproject.toml

@@ -5,6 +5,7 @@ requires-python = ">=3.12"
 
 [dependency-groups]
 dev = [
+  "aiounifi>=85",
   "caldav>=3.2.1",
   "icalendar>=7.1.3",
   "pytest-homeassistant-custom-component",

+ 40 - 1
tests/test_binary_sensor.py

@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
 
 import pytest
 
-from custom_components.room_cal_ha.binary_sensor import StaleSensor
+from custom_components.room_cal_ha.binary_sensor import StaleSensor, UnifiAvailableSensor
 from custom_components.room_cal_ha.coordinator import CoordinatorData
 from custom_components.room_cal_ha.const import (
   CONF_REFRESH_INTERVAL,
@@ -106,3 +106,42 @@ def test_stale_sensor_custom_interval():
   s = _sensor(current_ts=recent, next_ts=recent, interval=5)
   with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
     assert s.is_on is False
+
+
+# ---------------------------------------------------------------------------
+# UnifiAvailableSensor
+# ---------------------------------------------------------------------------
+
+def _entry(available: bool) -> MagicMock:
+  hub = MagicMock()
+  hub.available = available
+  e = MagicMock()
+  e.runtime_data = hub
+  return e
+
+
+def _unifi_sensor(entries=None) -> UnifiAvailableSensor:
+  s = object.__new__(UnifiAvailableSensor)
+  s.hass = MagicMock()
+  s.hass.config_entries.async_loaded_entries.return_value = entries if entries is not None else []
+  return s
+
+
+def test_unifi_sensor_no_entries_is_false():
+  assert _unifi_sensor([]).is_on is False
+
+
+def test_unifi_sensor_hub_unavailable_is_false():
+  assert _unifi_sensor([_entry(False)]).is_on is False
+
+
+def test_unifi_sensor_hub_available_is_true():
+  assert _unifi_sensor([_entry(True)]).is_on is True
+
+
+def test_unifi_sensor_first_unavailable_second_available_is_true():
+  assert _unifi_sensor([_entry(False), _entry(True)]).is_on is True
+
+
+def test_unifi_sensor_all_unavailable_is_false():
+  assert _unifi_sensor([_entry(False), _entry(False)]).is_on is False

+ 100 - 2
tests/test_coordinator.py

@@ -1,8 +1,9 @@
-from datetime import date, time
-from unittest.mock import AsyncMock, patch
+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
@@ -236,3 +237,100 @@ async def test_store_persists_across_reload(hass: HomeAssistant, mock_entry):
   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()

+ 95 - 0
tests/test_wifi.py

@@ -0,0 +1,95 @@
+"""Unit tests for async_set_wifi_key."""
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import aiounifi
+import pytest
+
+from homeassistant.exceptions import HomeAssistantError
+
+from custom_components.room_cal_ha.wifi import async_set_wifi_key
+
+
+def _wlan(name: str) -> MagicMock:
+  w = MagicMock()
+  w.name = name
+  return w
+
+
+def _hub(available: bool = True, wlans: dict | None = None) -> MagicMock:
+  hub = MagicMock()
+  hub.available = available
+  hub.api.wlans = wlans or {}
+  hub.api.request = AsyncMock()
+  return hub
+
+
+def _hass(*hubs: MagicMock) -> MagicMock:
+  hass = MagicMock()
+  entries = [MagicMock(runtime_data=hub) for hub in hubs]
+  hass.config_entries.async_loaded_entries.return_value = entries
+  return hass
+
+
+# ---------------------------------------------------------------------------
+# Not found cases
+# ---------------------------------------------------------------------------
+
+async def test_no_entries_raises():
+  with pytest.raises(HomeAssistantError, match="not found"):
+    await async_set_wifi_key(_hass(), "GuestNet", "password1")
+
+
+async def test_hub_unavailable_raises():
+  hub = _hub(available=False, wlans={"w1": _wlan("GuestNet")})
+  with pytest.raises(HomeAssistantError, match="not found"):
+    await async_set_wifi_key(_hass(hub), "GuestNet", "password1")
+
+
+async def test_ssid_not_matched_raises():
+  hub = _hub(wlans={"w1": _wlan("OtherNet")})
+  with pytest.raises(HomeAssistantError, match="not found"):
+    await async_set_wifi_key(_hass(hub), "GuestNet", "password1")
+
+
+# ---------------------------------------------------------------------------
+# Happy path
+# ---------------------------------------------------------------------------
+
+async def test_ssid_found_calls_request():
+  hub = _hub(wlans={"w1": _wlan("GuestNet")})
+  with patch("custom_components.room_cal_ha.wifi.WlanChangePasswordRequest.create") as mock_create:
+    mock_create.return_value = "fake_req"
+    await async_set_wifi_key(_hass(hub), "GuestNet", "password123")
+  mock_create.assert_called_once_with("w1", "password123")
+  hub.api.request.assert_awaited_once_with("fake_req")
+
+
+async def test_second_hub_used_when_first_lacks_ssid():
+  hub1 = _hub(wlans={"w1": _wlan("OtherNet")})
+  hub2 = _hub(wlans={"w2": _wlan("GuestNet")})
+  with patch("custom_components.room_cal_ha.wifi.WlanChangePasswordRequest.create") as mock_create:
+    mock_create.return_value = "req"
+    await async_set_wifi_key(_hass(hub1, hub2), "GuestNet", "password123")
+  mock_create.assert_called_once_with("w2", "password123")
+  hub1.api.request.assert_not_called()
+  hub2.api.request.assert_awaited_once()
+
+
+async def test_first_hub_used_when_both_have_ssid():
+  hub1 = _hub(wlans={"w1": _wlan("GuestNet")})
+  hub2 = _hub(wlans={"w2": _wlan("GuestNet")})
+  with patch("custom_components.room_cal_ha.wifi.WlanChangePasswordRequest.create"):
+    await async_set_wifi_key(_hass(hub1, hub2), "GuestNet", "password123")
+  hub1.api.request.assert_awaited_once()
+  hub2.api.request.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# API error
+# ---------------------------------------------------------------------------
+
+async def test_api_error_raises_ha_error():
+  hub = _hub(wlans={"w1": _wlan("GuestNet")})
+  hub.api.request = AsyncMock(side_effect=aiounifi.AiounifiException("bad"))
+  with pytest.raises(HomeAssistantError, match="Failed to set WiFi key"):
+    await async_set_wifi_key(_hass(hub), "GuestNet", "password123")

+ 63 - 0
uv.lock

@@ -652,6 +652,48 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
 ]
 
+[[package]]
+name = "aiounifi"
+version = "85"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.13'",
+]
+dependencies = [
+    { name = "aiohttp", version = "3.11.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+    { name = "orjson", version = "3.10.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+    { name = "segno", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/87/99/8de67dac0a152a43534a3e9674983411c96dc98d871f8d864ce8accab97c/aiounifi-85.tar.gz", hash = "sha256:75789ae629cb0b73cec373426b95c125256cfa86cfe8ca438fab1cf8a0379cb4", size = 54499, upload-time = "2025-08-06T19:38:31.589Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/08/df/9f06a6a358b4650ec711af1008a97ea68a3eb9bcb89bd460ab86a0606159/aiounifi-85-py3-none-any.whl", hash = "sha256:6ec58ef6064f2b310fb5b907051476301356ac6e2a8f4f13f5b8ed0bfcc3bc0a", size = 48939, upload-time = "2025-08-06T19:38:30.458Z" },
+]
+
+[[package]]
+name = "aiounifi"
+version = "91"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.14.2'",
+    "python_full_version >= '3.14' and python_full_version < '3.14.2'",
+    "python_full_version >= '3.13.2' and python_full_version < '3.14'",
+    "python_full_version >= '3.13' and python_full_version < '3.13.2'",
+]
+dependencies = [
+    { name = "aiohttp", version = "3.11.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' and python_full_version < '3.13.2'" },
+    { name = "aiohttp", version = "3.13.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13.2' and python_full_version < '3.14.2'" },
+    { name = "aiohttp", version = "3.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14.2'" },
+    { name = "orjson", version = "3.10.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' and python_full_version < '3.13.2'" },
+    { name = "orjson", version = "3.11.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13.2' and python_full_version < '3.14.2'" },
+    { name = "orjson", version = "3.11.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14.2'" },
+    { name = "pyotp", marker = "python_full_version >= '3.13'" },
+    { name = "segno", marker = "python_full_version >= '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/64/1a/977232c87dc91b70bdd5cba4d07e7a469f8d34bbc14de6b17297c9a59db6/aiounifi-91.tar.gz", hash = "sha256:ca0750a839071ac3d001be3734b55ad1426363f09349463f6d36554a41a18f67", size = 66516, upload-time = "2026-05-25T19:40:13.457Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2d/87/6ae7c0f7e8026eada3558bae51757bb3e2060c80735d96ef88aea34858d7/aiounifi-91-py3-none-any.whl", hash = "sha256:9685695e613b5656b30abdfffe0c4379defd6fa682d8c4d2364a47f30f0ab52d", size = 56274, upload-time = "2026-05-25T19:40:12.106Z" },
+]
+
 [[package]]
 name = "aiozoneinfo"
 version = "0.2.1"
@@ -5401,6 +5443,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
 ]
 
+[[package]]
+name = "pyotp"
+version = "2.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/c6/c5d96a86fd0bf6fa1bbb5c5c341ff3208638b692727a683c8289068d9a11/pyotp-2.10.0.tar.gz", hash = "sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f", size = 18625, upload-time = "2026-06-14T03:48:49.221Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/65/33/7b83bde70eddaaaaef487751a9c3a5cc0c0be54620ded0e120ebdc401ff9/pyotp-2.10.0-py3-none-any.whl", hash = "sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c", size = 13768, upload-time = "2026-06-14T03:48:47.831Z" },
+]
+
 [[package]]
 name = "pyrfc3339"
 version = "2.1.0"
@@ -6477,6 +6528,8 @@ source = { virtual = "." }
 
 [package.dev-dependencies]
 dev = [
+    { name = "aiounifi", version = "85", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+    { name = "aiounifi", version = "91", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
     { name = "caldav" },
     { name = "icalendar" },
     { name = "pytest-homeassistant-custom-component", version = "0.13.195", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
@@ -6500,6 +6553,7 @@ integration = [
 
 [package.metadata.requires-dev]
 dev = [
+    { name = "aiounifi", specifier = ">=85" },
     { name = "caldav", specifier = ">=3.2.1" },
     { name = "icalendar", specifier = ">=7.1.3" },
     { name = "pytest-homeassistant-custom-component" },
@@ -6572,6 +6626,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/fb/56/4ef84868d1ff2f77ac080929117283ac9d18f7d018ae64fa1e7f957a8d20/securetar-2026.4.1-py3-none-any.whl", hash = "sha256:7cf66c204d678b9d1e2d17814c83048a6468e928ea00edb7e6c1efe72aab0540", size = 19339, upload-time = "2026-04-07T17:02:05.635Z" },
 ]
 
+[[package]]
+name = "segno"
+version = "1.6.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/2e/b396f750c53f570055bf5a9fc1ace09bed2dff013c73b7afec5702a581ba/segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3", size = 1628586, upload-time = "2025-03-12T22:12:53.324Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" },
+]
+
 [[package]]
 name = "sentence-stream"
 version = "1.3.0"