Просмотр исходного кода

Add integration test profile and rewrite tests for read-only CalDAV account

Adds a uv `integration` dependency group that excludes
pytest-homeassistant-custom-component (and its socket restrictions),
allowing integration tests to reach a live CalDAV server.

Rewrites the three write-dependent tests to work against existing calendar
events: parses real Guest: events to verify _parse_event, checks that
non-prefixed and all-day events return None, and asserts X-ROOMCAL-DATA
fields round-trip correctly through the parser.

Run with: uv run --only-group integration pytest tests_integration/
ZiB 4 месяцев назад
Родитель
Сommit
8758290027
5 измененных файлов с 7423 добавлено и 0 удалено
  1. 7 0
      pyproject.toml
  2. 36 0
      tests_integration/conftest.py
  3. 2 0
      tests_integration/pytest.ini
  4. 189 0
      tests_integration/test_caldav_integration.py
  5. 7189 0
      uv.lock

+ 7 - 0
pyproject.toml

@@ -9,9 +9,16 @@ dev = [
   "icalendar>=7.1.3",
   "pytest-homeassistant-custom-component",
 ]
+integration = [
+  "caldav>=3.2.1",
+  "icalendar>=7.1.3",
+  "pytest>=8",
+  "pytest-asyncio>=0.24",
+]
 
 [tool.pytest.ini_options]
 testpaths = ["tests"]
 asyncio_mode = "auto"
 
+
 [tool.uv.sources]

+ 36 - 0
tests_integration/conftest.py

@@ -0,0 +1,36 @@
+"""Conftest for integration tests.
+
+Stubs out homeassistant and voluptuous so the component package can be
+imported without the full HA stack installed (integration group only needs
+caldav, icalendar, and pytest).
+"""
+import sys
+from pathlib import Path
+from types import ModuleType
+from unittest.mock import MagicMock
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parents[1]))
+
+_HA_MODULES = [
+  "voluptuous",
+  "homeassistant",
+  "homeassistant.config_entries",
+  "homeassistant.const",
+  "homeassistant.core",
+  "homeassistant.helpers",
+  "homeassistant.helpers.config_validation",
+  "homeassistant.helpers.entity",
+  "homeassistant.helpers.event",
+  "homeassistant.helpers.storage",
+  "homeassistant.helpers.update_coordinator",
+  "homeassistant.util",
+  "homeassistant.util.dt",
+  "homeassistant.components",
+  "homeassistant.components.sensor",
+]
+
+for _mod in _HA_MODULES:
+  if _mod not in sys.modules:
+    sys.modules[_mod] = MagicMock()

+ 2 - 0
tests_integration/pytest.ini

@@ -0,0 +1,2 @@
+[pytest]
+asyncio_mode = auto

+ 189 - 0
tests_integration/test_caldav_integration.py

@@ -0,0 +1,189 @@
+"""Integration tests against a live CalDAV server.
+
+Reads credentials from <project_root>/caldav.env (two levels above code/).
+Skipped automatically when the URL is not configured.
+
+Run with:
+    uv run --only-group integration pytest tests_integration/
+"""
+from __future__ import annotations
+
+from datetime import date, datetime, timedelta, timezone
+from pathlib import Path
+
+import caldav
+import pytest
+from icalendar import Calendar as iCal
+
+from custom_components.room_cal_ha.const import RESERVATION_TITLE_PREFIX, X_DATA_PROP
+from custom_components.room_cal_ha.coordinator import _parse_event
+
+_ENV_FILE = Path(__file__).parents[2] / "caldav.env"
+
+
+def _read_env() -> dict[str, str]:
+  result: dict[str, str] = {}
+  if _ENV_FILE.exists():
+    for line in _ENV_FILE.read_text().splitlines():
+      line = line.strip()
+      if "=" in line and not line.startswith("#"):
+        k, _, v = line.partition("=")
+        result[k.strip()] = v.strip()
+  return result
+
+
+@pytest.fixture
+def caldav_cfg():
+  env = _read_env()
+  url = env.get("url", "")
+  if not url:
+    pytest.skip("caldav.env: url not set")
+  return {
+    "url": url,
+    "username": env.get("user", ""),
+    "password": env.get("pass", ""),
+    "calendar_name": env.get("cal", "Room"),
+  }
+
+
+@pytest.fixture
+def calendar(caldav_cfg):
+  client = caldav.DAVClient(
+    url=caldav_cfg["url"],
+    username=caldav_cfg["username"],
+    password=caldav_cfg["password"],
+  )
+  principal = client.principal()
+  for cal in principal.calendars():
+    if cal.get_display_name() == caldav_cfg["calendar_name"]:
+      yield cal
+      client.close()
+      return
+  client.close()
+  pytest.skip(f"Calendar '{caldav_cfg['calendar_name']}' not found on server")
+
+
+# ── tests ─────────────────────────────────────────────────────────────────────
+
+def test_connection(caldav_cfg):
+  """Verify credentials are accepted and principal resolves."""
+  client = caldav.DAVClient(
+    url=caldav_cfg["url"],
+    username=caldav_cfg["username"],
+    password=caldav_cfg["password"],
+  )
+  try:
+    assert client.principal() is not None
+  finally:
+    client.close()
+
+
+def test_calendar_found(calendar):
+  """Verify the target calendar exists and has a name."""
+  assert calendar is not None
+  assert calendar.get_display_name()
+
+
+def test_parse_existing_reservation(calendar):
+  """Fetch real Guest: events from the server and verify _parse_event parses them."""
+  now = datetime.now(timezone.utc)
+  events = calendar.search(
+    start=now - timedelta(days=30),
+    end=now + timedelta(days=90),
+    event=True,
+    expand=True,
+  )
+  reservations = [r for r in (_parse_event(e) for e in events) if r is not None]
+  assert len(reservations) > 0, "Expected at least one parseable Guest: event on the calendar"
+
+  res = reservations[0]
+  assert res.guest_name is not None
+  assert res.check_in_date is not None
+  assert res.check_in_time is not None
+  assert res.check_out_date is not None
+  assert res.check_out_time is not None
+
+
+def test_x_data_fields_parsed_correctly(calendar):
+  """X-ROOMCAL-DATA JSON fields must match what _parse_event returns."""
+  import json as _json
+
+  now = datetime.now(timezone.utc)
+  events = calendar.search(
+    start=now - timedelta(days=30),
+    end=now + timedelta(days=90),
+    event=True,
+    expand=True,
+  )
+  checked = 0
+  for e in events:
+    cal = iCal.from_ical(e.data)
+    for comp in cal.walk():
+      if comp.name != "VEVENT":
+        continue
+      if not str(comp.get("SUMMARY", "")).startswith(RESERVATION_TITLE_PREFIX):
+        continue
+      raw = comp.get(X_DATA_PROP)
+      if not raw:
+        continue
+      x_data = _json.loads(str(raw))
+      res = _parse_event(e)
+      assert res is not None
+      assert res.guest_name == x_data.get("guest_name")
+      assert res.wifi_key == x_data.get("wifi_key")
+      assert res.door_code == x_data.get("door_code")
+      checked += 1
+
+  assert checked > 0, "No Guest: events with X-ROOMCAL-DATA found to verify"
+
+
+def test_event_without_prefix_is_ignored(calendar):
+  """Existing non-Guest events on the server must not be parsed as reservations."""
+  now = datetime.now(timezone.utc)
+  events = calendar.search(
+    start=now - timedelta(days=30),
+    end=now + timedelta(days=90),
+    event=True,
+    expand=True,
+  )
+  non_guest = []
+  for e in events:
+    cal = iCal.from_ical(e.data)
+    for comp in cal.walk():
+      if comp.name == "VEVENT":
+        summary = str(comp.get("SUMMARY", ""))
+        if not summary.startswith(RESERVATION_TITLE_PREFIX):
+          non_guest.append(e)
+          break
+
+  if not non_guest:
+    pytest.skip("No non-Guest events found in the search window to test against")
+
+  for e in non_guest:
+    assert _parse_event(e) is None, "Expected None for non-Guest event"
+
+
+def test_all_day_event_is_ignored(calendar):
+  """All-day events (date DTSTART) on the server must return None from _parse_event."""
+  now = datetime.now(timezone.utc)
+  events = calendar.search(
+    start=now - timedelta(days=30),
+    end=now + timedelta(days=90),
+    event=True,
+    expand=False,
+  )
+  all_day = []
+  for e in events:
+    cal = iCal.from_ical(e.data)
+    for comp in cal.walk():
+      if comp.name == "VEVENT":
+        dtstart = comp.get("DTSTART")
+        if dtstart is not None and isinstance(dtstart.dt, date) and not isinstance(dtstart.dt, datetime):
+          all_day.append(e)
+          break
+
+  if not all_day:
+    pytest.skip("No all-day events found in the search window to test against")
+
+  for e in all_day:
+    assert _parse_event(e) is None, "Expected None for all-day event"

Разница между файлами не показана из-за своего большого размера
+ 7189 - 0
uv.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов