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