Sfoglia il codice sorgente

Add comprehensive tests for new and previously untested code

- test_models: ReservationData serialization round-trip, apply_partial
  edge cases (overlay, None clear, unknown key ignore), ApiStatusData defaults
- test_api_backend: _parse_reservation, async_fetch classification
  (current/next, multi-day dedup, empty), error paths (401/403/500/network),
  async_fetch_status (ok, stale, auth_error, network error, bad checked_at)
- test_caldav_backend: _parse_event valid parse, date/time extraction (UTC),
  all filtering paths (wrong title, all-day, missing X-data, bad JSON, garbage),
  partial data (missing DTEND, sparse X-ROOMCAL-DATA)
- test_reauth_flow: CalDAV and API reauth full flow — form dispatch, invalid_auth,
  cannot_connect, success (with backend patch + async_block_till_done to handle
  the triggered entry reload cleanly)
- test_sensors: all sensor property logic via object.__new__ injection —
  str/time/datetime/wifi-key/last-refresh sensors plus ApiStatusSensor and
  ApiCheckedAtSensor native_value and extra_state_attributes
ZiB 4 mesi fa
parent
commit
af47a7a3c3
5 ha cambiato i file con 1099 aggiunte e 0 eliminazioni
  1. 303 0
      tests/test_api_backend.py
  2. 156 0
      tests/test_caldav_backend.py
  3. 174 0
      tests/test_models.py
  4. 184 0
      tests/test_reauth_flow.py
  5. 282 0
      tests/test_sensors.py

+ 303 - 0
tests/test_api_backend.py

@@ -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

+ 156 - 0
tests/test_caldav_backend.py

@@ -0,0 +1,156 @@
+"""Unit tests for _parse_event in the CalDAV backend."""
+import json
+from datetime import date, time
+from unittest.mock import MagicMock
+
+import pytest
+from homeassistant.core import HomeAssistant
+
+from custom_components.room_cal_ha.backends.caldav import _parse_event
+from custom_components.room_cal_ha.models import ReservationData
+
+
+# ---------------------------------------------------------------------------
+# iCal helpers
+# ---------------------------------------------------------------------------
+
+_X_DATA = {
+  "guest_name": "Alice Smith",
+  "wifi_key": "secret-key",
+  "door_code": "4567",
+}
+
+
+def _make_event(summary: str, dtstart: str, dtend: str, x_data=None, all_day=False):
+  """Build a mock caldav.Event with the given iCal fields."""
+  if all_day:
+    start_line = f"DTSTART;VALUE=DATE:{dtstart}"
+    end_line = f"DTEND;VALUE=DATE:{dtend}"
+  else:
+    start_line = f"DTSTART:{dtstart}Z"
+    end_line = f"DTEND:{dtend}Z"
+
+  lines = [
+    "BEGIN:VCALENDAR",
+    "VERSION:2.0",
+    "BEGIN:VEVENT",
+    f"SUMMARY:{summary}",
+    start_line,
+    end_line,
+  ]
+  if x_data is not None:
+    lines.append(f"X-ROOMCAL-DATA:{json.dumps(x_data)}")
+  lines += ["END:VEVENT", "END:VCALENDAR", ""]
+
+  event = MagicMock()
+  event.data = "\r\n".join(lines)
+  return event
+
+
+# ---------------------------------------------------------------------------
+# Valid event
+# ---------------------------------------------------------------------------
+
+def test_parse_valid_event_returns_reservation_data(hass: HomeAssistant):
+  event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
+  result = _parse_event(event)
+
+  assert isinstance(result, ReservationData)
+  assert result.guest_name == "Alice Smith"
+  assert result.wifi_key == "secret-key"
+  assert result.door_code == "4567"
+
+
+def test_parse_valid_event_extracts_dates(hass: HomeAssistant):
+  event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
+  result = _parse_event(event)
+
+  assert result is not None
+  assert result.check_in_date == date(2026, 7, 1)
+  assert result.check_out_date == date(2026, 7, 5)
+
+
+def test_parse_valid_event_extracts_times(hass: HomeAssistant):
+  from zoneinfo import ZoneInfo
+  from homeassistant.util import dt as dt_util
+
+  dt_util.set_default_time_zone(ZoneInfo("UTC"))
+  event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", _X_DATA)
+  result = _parse_event(event)
+
+  assert result is not None
+  assert result.check_in_time == time(14, 0)
+  assert result.check_out_time == time(12, 0)
+
+
+# ---------------------------------------------------------------------------
+# Filtering — events that should return None
+# ---------------------------------------------------------------------------
+
+def test_parse_wrong_title_prefix_returns_none(hass: HomeAssistant):
+  event = _make_event("Blocked", "20260701T140000", "20260705T120000", _X_DATA)
+  assert _parse_event(event) is None
+
+
+def test_parse_all_day_event_returns_none(hass: HomeAssistant):
+  event = _make_event("Guest: Alice", "20260701", "20260705", _X_DATA, all_day=True)
+  assert _parse_event(event) is None
+
+
+def test_parse_missing_x_data_returns_none(hass: HomeAssistant):
+  event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000", x_data=None)
+  assert _parse_event(event) is None
+
+
+def test_parse_invalid_json_in_x_data_returns_none(hass: HomeAssistant):
+  event = _make_event("Guest: Alice", "20260701T140000", "20260705T120000")
+  # Manually inject bad JSON into the iCal
+  event.data = event.data.replace("END:VEVENT", "X-ROOMCAL-DATA:not-valid-json\r\nEND:VEVENT")
+  assert _parse_event(event) is None
+
+
+def test_parse_garbage_ical_returns_none(hass: HomeAssistant):
+  event = MagicMock()
+  event.data = "this is not ical at all"
+  # Should not raise — catches all exceptions
+  result = _parse_event(event)
+  assert result is None
+
+
+# ---------------------------------------------------------------------------
+# Partial data
+# ---------------------------------------------------------------------------
+
+def test_parse_event_missing_dtend_still_returns_result(hass: HomeAssistant):
+  """DTEND is optional — event without it should still parse (checkout fields will be None)."""
+  lines = [
+    "BEGIN:VCALENDAR",
+    "VERSION:2.0",
+    "BEGIN:VEVENT",
+    "SUMMARY:Guest: Alice",
+    "DTSTART:20260701T140000Z",
+    f"X-ROOMCAL-DATA:{json.dumps(_X_DATA)}",
+    "END:VEVENT",
+    "END:VCALENDAR",
+    "",
+  ]
+  event = MagicMock()
+  event.data = "\r\n".join(lines)
+  result = _parse_event(event)
+
+  assert result is not None
+  assert result.check_in_date == date(2026, 7, 1)
+  assert result.check_out_date is None
+  assert result.check_out_time is None
+
+
+def test_parse_event_x_data_missing_optional_fields(hass: HomeAssistant):
+  """X-ROOMCAL-DATA may omit wifi_key / door_code — those become None."""
+  sparse = {"guest_name": "Bob"}
+  event = _make_event("Guest: Bob", "20260801T140000", "20260803T120000", sparse)
+  result = _parse_event(event)
+
+  assert result is not None
+  assert result.guest_name == "Bob"
+  assert result.wifi_key is None
+  assert result.door_code is None

+ 174 - 0
tests/test_models.py

@@ -0,0 +1,174 @@
+from datetime import date, datetime, time, timezone
+
+import pytest
+
+from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
+
+
+# ---------------------------------------------------------------------------
+# ReservationData — serialization
+# ---------------------------------------------------------------------------
+
+def test_to_dict_serializes_dates_and_times():
+  res = 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="abc",
+    door_code="1234",
+  )
+  d = res.to_dict()
+  assert d["guest_name"] == "Alice"
+  assert d["check_in_date"] == "2026-07-01"
+  assert d["check_in_time"] == "14:00:00"
+  assert d["check_out_date"] == "2026-07-05"
+  assert d["check_out_time"] == "12:00:00"
+  assert d["wifi_key"] == "abc"
+  assert d["door_code"] == "1234"
+
+
+def test_to_dict_none_fields_stay_none():
+  res = ReservationData(guest_name=None, check_in_date=None, wifi_key=None)
+  d = res.to_dict()
+  assert d["guest_name"] is None
+  assert d["check_in_date"] is None
+  assert d["wifi_key"] is None
+
+
+def test_from_dict_deserializes_iso_strings():
+  d = {
+    "guest_name": "Bob",
+    "check_in_date": "2026-08-01",
+    "check_in_time": "15:00:00",
+    "check_out_date": "2026-08-04",
+    "check_out_time": "11:00:00",
+    "wifi_ssid": "Net",
+    "wifi_key": "xyz",
+    "door_code": "9876",
+  }
+  res = ReservationData.from_dict(d)
+  assert res.guest_name == "Bob"
+  assert res.check_in_date == date(2026, 8, 1)
+  assert res.check_in_time == time(15, 0)
+  assert res.check_out_date == date(2026, 8, 4)
+  assert res.check_out_time == time(11, 0)
+  assert res.wifi_key == "xyz"
+  assert res.door_code == "9876"
+
+
+def test_from_dict_none_fields():
+  d = {
+    "guest_name": None,
+    "check_in_date": None,
+    "check_in_time": None,
+    "check_out_date": None,
+    "check_out_time": None,
+    "wifi_ssid": None,
+    "wifi_key": None,
+    "door_code": None,
+  }
+  res = ReservationData.from_dict(d)
+  assert res.guest_name is None
+  assert res.check_in_date is None
+  assert res.check_in_time is None
+
+
+def test_round_trip():
+  original = ReservationData(
+    guest_name="Carol",
+    check_in_date=date(2026, 9, 10),
+    check_in_time=time(14, 30),
+    check_out_date=date(2026, 9, 14),
+    check_out_time=time(11, 0),
+    wifi_ssid="HomeNet",
+    wifi_key="pw",
+    door_code="0000",
+  )
+  assert ReservationData.from_dict(original.to_dict()) == original
+
+
+# ---------------------------------------------------------------------------
+# ReservationData — apply_partial
+# ---------------------------------------------------------------------------
+
+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_ssid="4114",
+  wifi_key="stone",
+  door_code="0472",
+)
+
+
+def test_apply_partial_overlays_specified_fields():
+  updated = SAMPLE.apply_partial({"wifi_ssid": "GuestNet", "door_code": "9999"})
+  assert updated.wifi_ssid == "GuestNet"
+  assert updated.door_code == "9999"
+
+
+def test_apply_partial_leaves_other_fields_unchanged():
+  updated = SAMPLE.apply_partial({"door_code": "1111"})
+  assert updated.guest_name == "Alice"
+  assert updated.check_in_date == date(2026, 7, 1)
+  assert updated.wifi_key == "stone"
+
+
+def test_apply_partial_can_clear_a_field_with_none():
+  updated = SAMPLE.apply_partial({"guest_name": None})
+  assert updated.guest_name is None
+
+
+def test_apply_partial_ignores_unknown_keys():
+  updated = SAMPLE.apply_partial({"nonexistent_field": "x"})
+  assert updated == SAMPLE
+
+
+def test_apply_partial_empty_dict_returns_equivalent():
+  updated = SAMPLE.apply_partial({})
+  assert updated == SAMPLE
+
+
+# ---------------------------------------------------------------------------
+# ReservationData — defaults
+# ---------------------------------------------------------------------------
+
+def test_default_values():
+  res = ReservationData()
+  assert res.guest_name == "<No Guest>"
+  assert res.check_in_time == time(14, 0)
+  assert res.check_out_time == time(12, 0)
+  assert res.wifi_ssid == "4114"
+  assert res.check_in_date is None
+  assert res.check_out_date is None
+  assert res.wifi_key is None
+  assert res.door_code is None
+
+
+# ---------------------------------------------------------------------------
+# ApiStatusData — defaults
+# ---------------------------------------------------------------------------
+
+def test_api_status_defaults():
+  s = ApiStatusData()
+  assert s.status == "unknown"
+  assert s.checks == {}
+  assert s.checked_at is None
+  assert s.stale is False
+
+
+def test_api_status_with_data():
+  ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
+  s = ApiStatusData(
+    status="ok",
+    checks={"caldav_connection": {"ok": True}},
+    checked_at=ts,
+    stale=False,
+  )
+  assert s.status == "ok"
+  assert s.checks["caldav_connection"]["ok"] is True
+  assert s.checked_at == ts

+ 184 - 0
tests/test_reauth_flow.py

@@ -0,0 +1,184 @@
+"""Tests for the reauth flows (CalDAV and API modes)."""
+from unittest.mock import AsyncMock, patch
+
+import caldav.lib.error as caldav_error
+import pytest
+from homeassistant.config_entries import SOURCE_REAUTH
+from homeassistant.core import HomeAssistant
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.room_cal_ha.const import (
+  CONF_API_BASE_URL,
+  CONF_API_TOKEN,
+  CONF_CALDAV_URL,
+  CONF_PASSWORD,
+  CONF_SYNC_MODE,
+  CONF_USERNAME,
+  DOMAIN,
+  SYNC_MODE_API,
+  SYNC_MODE_CALDAV,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def caldav_entry(hass: HomeAssistant):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
+    options={CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com"},
+  )
+  entry.add_to_hass(hass)
+  return entry
+
+
+@pytest.fixture
+def api_entry(hass: HomeAssistant):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_API_TOKEN: "old-token"},
+    options={CONF_SYNC_MODE: SYNC_MODE_API, CONF_API_BASE_URL: "https://api.example.com"},
+  )
+  entry.add_to_hass(hass)
+  return entry
+
+
+def _start_reauth(hass, entry):
+  return hass.config_entries.flow.async_init(
+    DOMAIN,
+    context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id},
+    data=entry.data,
+  )
+
+
+# ---------------------------------------------------------------------------
+# CalDAV reauth
+# ---------------------------------------------------------------------------
+
+async def test_caldav_reauth_shows_confirm_form(hass: HomeAssistant, caldav_entry):
+  result = await _start_reauth(hass, caldav_entry)
+  assert result["type"] == "form"
+  assert result["step_id"] == "reauth_confirm"
+
+
+async def test_caldav_reauth_bad_credentials_shows_invalid_auth(hass: HomeAssistant, caldav_entry):
+  result = await _start_reauth(hass, caldav_entry)
+
+  with patch(
+    "custom_components.room_cal_ha.config_flow._test_caldav_auth",
+    side_effect=caldav_error.AuthorizationError,
+  ):
+    result = await hass.config_entries.flow.async_configure(
+      result["flow_id"],
+      user_input={CONF_USERNAME: "user", CONF_PASSWORD: "wrong"},
+    )
+
+  assert result["type"] == "form"
+  assert result["errors"]["base"] == "invalid_auth"
+
+
+async def test_caldav_reauth_connect_error_shows_cannot_connect(hass: HomeAssistant, caldav_entry):
+  result = await _start_reauth(hass, caldav_entry)
+
+  with patch(
+    "custom_components.room_cal_ha.config_flow._test_caldav_auth",
+    side_effect=Exception("timeout"),
+  ):
+    result = await hass.config_entries.flow.async_configure(
+      result["flow_id"],
+      user_input={CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
+    )
+
+  assert result["type"] == "form"
+  assert result["errors"]["base"] == "cannot_connect"
+
+
+async def test_caldav_reauth_success_updates_credentials(hass: HomeAssistant, caldav_entry):
+  result = await _start_reauth(hass, caldav_entry)
+
+  with (
+    patch("custom_components.room_cal_ha.config_flow._test_caldav_auth", return_value=None),
+    patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend,
+  ):
+    MockBackend.return_value.async_fetch = AsyncMock(return_value=(None, None))
+    result = await hass.config_entries.flow.async_configure(
+      result["flow_id"],
+      user_input={CONF_USERNAME: "new_user", CONF_PASSWORD: "new_pass"},
+    )
+    # Drain the event loop so the triggered entry reload completes while patches are active.
+    await hass.async_block_till_done()
+
+  assert result["type"] == "abort"
+  assert result["reason"] == "reauth_successful"
+  assert caldav_entry.data[CONF_USERNAME] == "new_user"
+  assert caldav_entry.data[CONF_PASSWORD] == "new_pass"
+
+
+# ---------------------------------------------------------------------------
+# API reauth
+# ---------------------------------------------------------------------------
+
+async def test_api_reauth_shows_api_form(hass: HomeAssistant, api_entry):
+  result = await _start_reauth(hass, api_entry)
+  assert result["type"] == "form"
+  assert result["step_id"] == "reauth_api"
+
+
+async def test_api_reauth_bad_token_shows_invalid_auth(hass: HomeAssistant, api_entry):
+  from custom_components.room_cal_ha.config_flow import _ApiAuthError
+
+  result = await _start_reauth(hass, api_entry)
+
+  with patch(
+    "custom_components.room_cal_ha.config_flow._test_api_connection",
+    new=AsyncMock(side_effect=_ApiAuthError),
+  ):
+    result = await hass.config_entries.flow.async_configure(
+      result["flow_id"],
+      user_input={CONF_API_TOKEN: "bad-token"},
+    )
+
+  assert result["type"] == "form"
+  assert result["errors"]["base"] == "invalid_auth"
+
+
+async def test_api_reauth_connect_error_shows_cannot_connect(hass: HomeAssistant, api_entry):
+  result = await _start_reauth(hass, api_entry)
+
+  with patch(
+    "custom_components.room_cal_ha.config_flow._test_api_connection",
+    new=AsyncMock(side_effect=Exception("timeout")),
+  ):
+    result = await hass.config_entries.flow.async_configure(
+      result["flow_id"],
+      user_input={CONF_API_TOKEN: "tok"},
+    )
+
+  assert result["type"] == "form"
+  assert result["errors"]["base"] == "cannot_connect"
+
+
+async def test_api_reauth_success_updates_token(hass: HomeAssistant, api_entry):
+  from custom_components.room_cal_ha.models import ApiStatusData
+
+  result = await _start_reauth(hass, api_entry)
+
+  with (
+    patch("custom_components.room_cal_ha.config_flow._test_api_connection", new=AsyncMock(return_value=None)),
+    patch("custom_components.room_cal_ha.coordinator.RoomCalApiBackend") as MockBackend,
+  ):
+    instance = MockBackend.return_value
+    instance.async_fetch = AsyncMock(return_value=(None, None))
+    instance.async_fetch_status = AsyncMock(return_value=ApiStatusData(status="ok"))
+    result = await hass.config_entries.flow.async_configure(
+      result["flow_id"],
+      user_input={CONF_API_TOKEN: "fresh-token"},
+    )
+    await hass.async_block_till_done()
+
+  assert result["type"] == "abort"
+  assert result["reason"] == "reauth_successful"
+  assert api_entry.data[CONF_API_TOKEN] == "fresh-token"

+ 282 - 0
tests/test_sensors.py

@@ -0,0 +1,282 @@
+"""Unit tests for sensor property logic.
+
+Bypasses HA entity setup by using object.__new__ and injecting a mock
+coordinator — valid because every tested property only reads self.coordinator.data.
+"""
+from datetime import date, datetime, time, timezone
+from unittest.mock import MagicMock
+
+import pytest
+
+from custom_components.room_cal_ha.coordinator import CoordinatorData
+from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
+from custom_components.room_cal_ha.sensor import (
+  ApiCheckedAtSensor,
+  ApiStatusSensor,
+  LastRefreshSensor,
+  ReservationDateTimeSensor,
+  ReservationStrSensor,
+  ReservationTimeSensor,
+  WiFiKeySensor,
+)
+from custom_components.room_cal_ha.const import STORE_CURRENT, STORE_NEXT
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _coordinator(
+  current=None,
+  nxt=None,
+  current_ts=None,
+  next_ts=None,
+  api_status=None,
+):
+  coord = MagicMock()
+  coord.data = CoordinatorData(
+    current=current or ReservationData(),
+    next=nxt or ReservationData(),
+    current_last_refresh=current_ts,
+    next_last_refresh=next_ts,
+    api_status=api_status,
+  )
+  return coord
+
+
+def _sensor(cls, coordinator, slot=STORE_CURRENT, field="guest_name", **extra):
+  """Create a sensor instance without running __init__."""
+  s = object.__new__(cls)
+  s.coordinator = coordinator
+  s._slot = slot
+  s._field = field
+  for k, v in extra.items():
+    setattr(s, k, v)
+  return s
+
+
+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_ssid="MyNet",
+  wifi_key="hunter2",
+  door_code="0472",
+)
+
+
+# ---------------------------------------------------------------------------
+# ReservationStrSensor
+# ---------------------------------------------------------------------------
+
+def test_str_sensor_returns_field_value():
+  coord = _coordinator(current=SAMPLE)
+  s = _sensor(ReservationStrSensor, coord, field="guest_name")
+  assert s.native_value == "Alice"
+
+
+def test_str_sensor_reads_next_slot():
+  nxt = ReservationData(guest_name="Bob")
+  coord = _coordinator(nxt=nxt)
+  s = _sensor(ReservationStrSensor, coord, slot=STORE_NEXT, field="guest_name")
+  assert s.native_value == "Bob"
+
+
+def test_str_sensor_none_when_field_is_none():
+  res = ReservationData(guest_name=None)
+  coord = _coordinator(current=res)
+  s = _sensor(ReservationStrSensor, coord, field="guest_name")
+  assert s.native_value is None
+
+
+# ---------------------------------------------------------------------------
+# ReservationTimeSensor
+# ---------------------------------------------------------------------------
+
+def test_time_sensor_returns_iso_string():
+  coord = _coordinator(current=SAMPLE)
+  s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
+  assert s.native_value == "14:00:00"
+
+
+def test_time_sensor_none_when_time_is_none():
+  res = ReservationData(check_in_time=None)
+  coord = _coordinator(current=res)
+  s = _sensor(ReservationTimeSensor, coord, field="check_in_time")
+  assert s.native_value is None
+
+
+# ---------------------------------------------------------------------------
+# ReservationDateTimeSensor
+# ---------------------------------------------------------------------------
+
+def test_datetime_sensor_native_value_is_date():
+  coord = _coordinator(current=SAMPLE)
+  s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
+  s._time_field = "check_in_time"
+  assert s.native_value == date(2026, 7, 1)
+
+
+def test_datetime_sensor_attrs_contain_expected_keys():
+  coord = _coordinator(current=SAMPLE)
+  s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
+  s._time_field = "check_in_time"
+  attrs = s.extra_state_attributes
+  assert attrs is not None
+  for key in ("datetime", "date", "weekday", "month", "time_abbr", "hour"):
+    assert key in attrs, f"missing key: {key}"
+
+
+def test_datetime_sensor_attrs_none_when_date_missing():
+  res = ReservationData(check_in_date=None, check_in_time=time(14, 0))
+  coord = _coordinator(current=res)
+  s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
+  s._time_field = "check_in_time"
+  assert s.extra_state_attributes is None
+
+
+def test_datetime_sensor_attrs_none_when_time_missing():
+  res = ReservationData(check_in_date=date(2026, 7, 1), check_in_time=None)
+  coord = _coordinator(current=res)
+  s = _sensor(ReservationDateTimeSensor, coord, field="check_in_date")
+  s._time_field = "check_in_time"
+  assert s.extra_state_attributes is None
+
+
+# ---------------------------------------------------------------------------
+# WiFiKeySensor
+# ---------------------------------------------------------------------------
+
+def test_wifi_key_sensor_native_value():
+  coord = _coordinator(current=SAMPLE)
+  s = _sensor(WiFiKeySensor, coord, field="wifi_key")
+  assert s.native_value == "hunter2"
+
+
+def test_wifi_key_sensor_qr_attribute():
+  coord = _coordinator(current=SAMPLE)
+  s = _sensor(WiFiKeySensor, coord, field="wifi_key")
+  attrs = s.extra_state_attributes
+  assert attrs is not None
+  assert attrs["qr"] == "WIFI:S:MyNet;T:WPA;P:hunter2;;"
+
+
+def test_wifi_key_sensor_no_attrs_when_ssid_missing():
+  res = ReservationData(wifi_ssid=None, wifi_key="hunter2")
+  coord = _coordinator(current=res)
+  s = _sensor(WiFiKeySensor, coord, field="wifi_key")
+  assert s.extra_state_attributes is None
+
+
+def test_wifi_key_sensor_no_attrs_when_key_missing():
+  res = ReservationData(wifi_ssid="MyNet", wifi_key=None)
+  coord = _coordinator(current=res)
+  s = _sensor(WiFiKeySensor, coord, field="wifi_key")
+  assert s.extra_state_attributes is None
+
+
+# ---------------------------------------------------------------------------
+# LastRefreshSensor
+# ---------------------------------------------------------------------------
+
+def test_last_refresh_sensor_current():
+  ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
+  coord = _coordinator(current_ts=ts)
+  s = _sensor(LastRefreshSensor, coord)
+  s._slot = STORE_CURRENT
+  assert s.native_value == ts
+
+
+def test_last_refresh_sensor_next():
+  ts = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
+  coord = _coordinator(next_ts=ts)
+  s = _sensor(LastRefreshSensor, coord)
+  s._slot = STORE_NEXT
+  assert s.native_value == ts
+
+
+def test_last_refresh_sensor_none_before_any_fetch():
+  coord = _coordinator()
+  s = _sensor(LastRefreshSensor, coord)
+  s._slot = STORE_CURRENT
+  assert s.native_value is None
+
+
+# ---------------------------------------------------------------------------
+# ApiStatusSensor
+# ---------------------------------------------------------------------------
+
+def test_api_status_sensor_native_value():
+  status = ApiStatusData(status="ok")
+  coord = _coordinator(api_status=status)
+  s = object.__new__(ApiStatusSensor)
+  s.coordinator = coord
+  assert s.native_value == "ok"
+
+
+def test_api_status_sensor_none_when_no_status():
+  coord = _coordinator(api_status=None)
+  s = object.__new__(ApiStatusSensor)
+  s.coordinator = coord
+  assert s.native_value is None
+
+
+def test_api_status_sensor_attrs_include_checks_and_stale():
+  checks = {"caldav_connection": {"ok": True}, "calendar": {"ok": False, "reason": "not found"}}
+  status = ApiStatusData(status="degraded", checks=checks, stale=True)
+  coord = _coordinator(api_status=status)
+  s = object.__new__(ApiStatusSensor)
+  s.coordinator = coord
+
+  attrs = s.extra_state_attributes
+  assert attrs["checks"] == checks
+  assert attrs["stale"] is True
+
+
+def test_api_status_sensor_attrs_include_checked_at_when_present():
+  ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
+  status = ApiStatusData(status="ok", checked_at=ts)
+  coord = _coordinator(api_status=status)
+  s = object.__new__(ApiStatusSensor)
+  s.coordinator = coord
+
+  attrs = s.extra_state_attributes
+  assert "checked_at" in attrs
+  assert attrs["checked_at"] == ts.isoformat()
+
+
+def test_api_status_sensor_attrs_none_when_no_status():
+  coord = _coordinator(api_status=None)
+  s = object.__new__(ApiStatusSensor)
+  s.coordinator = coord
+  assert s.extra_state_attributes is None
+
+
+# ---------------------------------------------------------------------------
+# ApiCheckedAtSensor
+# ---------------------------------------------------------------------------
+
+def test_api_checked_at_sensor_returns_timestamp():
+  ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
+  status = ApiStatusData(checked_at=ts)
+  coord = _coordinator(api_status=status)
+  s = object.__new__(ApiCheckedAtSensor)
+  s.coordinator = coord
+  assert s.native_value == ts
+
+
+def test_api_checked_at_sensor_none_when_no_status():
+  coord = _coordinator(api_status=None)
+  s = object.__new__(ApiCheckedAtSensor)
+  s.coordinator = coord
+  assert s.native_value is None
+
+
+def test_api_checked_at_sensor_none_when_checked_at_not_set():
+  status = ApiStatusData(status="starting")
+  coord = _coordinator(api_status=status)
+  s = object.__new__(ApiCheckedAtSensor)
+  s.coordinator = coord
+  assert s.native_value is None