test_caldav_integration.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. """Integration tests against a live CalDAV server.
  2. Reads credentials from <project_root>/caldav.env (two levels above code/).
  3. Skipped automatically when the URL is not configured.
  4. Run with:
  5. uv run --only-group integration pytest tests_integration/
  6. """
  7. from __future__ import annotations
  8. from datetime import date, datetime, timedelta, timezone
  9. from pathlib import Path
  10. import caldav
  11. import pytest
  12. from icalendar import Calendar as iCal
  13. from custom_components.room_cal_ha.const import RESERVATION_TITLE_PREFIX, X_DATA_PROP
  14. from custom_components.room_cal_ha.backends.caldav import _parse_event
  15. _ENV_FILE = Path(__file__).parents[2] / "caldav.env"
  16. def _read_env() -> dict[str, str]:
  17. result: dict[str, str] = {}
  18. if _ENV_FILE.exists():
  19. for line in _ENV_FILE.read_text().splitlines():
  20. line = line.strip()
  21. if "=" in line and not line.startswith("#"):
  22. k, _, v = line.partition("=")
  23. result[k.strip()] = v.strip()
  24. return result
  25. @pytest.fixture
  26. def caldav_cfg():
  27. env = _read_env()
  28. url = env.get("url", "")
  29. if not url:
  30. pytest.skip("caldav.env: url not set")
  31. return {
  32. "url": url,
  33. "username": env.get("user", ""),
  34. "password": env.get("pass", ""),
  35. "calendar_name": env.get("cal", "Room"),
  36. }
  37. @pytest.fixture
  38. def calendar(caldav_cfg):
  39. client = caldav.DAVClient(
  40. url=caldav_cfg["url"],
  41. username=caldav_cfg["username"],
  42. password=caldav_cfg["password"],
  43. )
  44. principal = client.principal()
  45. for cal in principal.calendars():
  46. if cal.get_display_name() == caldav_cfg["calendar_name"]:
  47. yield cal
  48. client.close()
  49. return
  50. client.close()
  51. pytest.skip(f"Calendar '{caldav_cfg['calendar_name']}' not found on server")
  52. # ── tests ─────────────────────────────────────────────────────────────────────
  53. def test_connection(caldav_cfg):
  54. """Verify credentials are accepted and principal resolves."""
  55. client = caldav.DAVClient(
  56. url=caldav_cfg["url"],
  57. username=caldav_cfg["username"],
  58. password=caldav_cfg["password"],
  59. )
  60. try:
  61. assert client.principal() is not None
  62. finally:
  63. client.close()
  64. def test_calendar_found(calendar):
  65. """Verify the target calendar exists and has a name."""
  66. assert calendar is not None
  67. assert calendar.get_display_name()
  68. def test_parse_existing_reservation(calendar):
  69. """Fetch real Guest: events from the server and verify _parse_event parses them."""
  70. now = datetime.now(timezone.utc)
  71. events = calendar.search(
  72. start=now - timedelta(days=30),
  73. end=now + timedelta(days=90),
  74. event=True,
  75. expand=True,
  76. )
  77. reservations = [r for r in (_parse_event(e) for e in events) if r is not None]
  78. assert len(reservations) > 0, "Expected at least one parseable Guest: event on the calendar"
  79. res = reservations[0]
  80. assert res.guest_name is not None
  81. assert res.check_in_date is not None
  82. assert res.check_in_time is not None
  83. assert res.check_out_date is not None
  84. assert res.check_out_time is not None
  85. def test_x_data_fields_parsed_correctly(calendar):
  86. """X-ROOMCAL-DATA JSON fields must match what _parse_event returns."""
  87. import json as _json
  88. now = datetime.now(timezone.utc)
  89. events = calendar.search(
  90. start=now - timedelta(days=30),
  91. end=now + timedelta(days=90),
  92. event=True,
  93. expand=True,
  94. )
  95. checked = 0
  96. for e in events:
  97. cal = iCal.from_ical(e.data)
  98. for comp in cal.walk():
  99. if comp.name != "VEVENT":
  100. continue
  101. if not str(comp.get("SUMMARY", "")).startswith(RESERVATION_TITLE_PREFIX):
  102. continue
  103. raw = comp.get(X_DATA_PROP)
  104. if not raw:
  105. continue
  106. x_data = _json.loads(str(raw))
  107. res = _parse_event(e)
  108. assert res is not None
  109. assert res.guest_name == x_data.get("guest_name")
  110. assert res.wifi_key == x_data.get("wifi_key")
  111. assert res.door_code == x_data.get("door_code")
  112. checked += 1
  113. assert checked > 0, "No Guest: events with X-ROOMCAL-DATA found to verify"
  114. def test_event_without_prefix_is_ignored(calendar):
  115. """Existing non-Guest events on the server must not be parsed as reservations."""
  116. now = datetime.now(timezone.utc)
  117. events = calendar.search(
  118. start=now - timedelta(days=30),
  119. end=now + timedelta(days=90),
  120. event=True,
  121. expand=True,
  122. )
  123. non_guest = []
  124. for e in events:
  125. cal = iCal.from_ical(e.data)
  126. for comp in cal.walk():
  127. if comp.name == "VEVENT":
  128. summary = str(comp.get("SUMMARY", ""))
  129. if not summary.startswith(RESERVATION_TITLE_PREFIX):
  130. non_guest.append(e)
  131. break
  132. if not non_guest:
  133. pytest.skip("No non-Guest events found in the search window to test against")
  134. for e in non_guest:
  135. assert _parse_event(e) is None, "Expected None for non-Guest event"
  136. def test_all_day_event_is_ignored(calendar):
  137. """All-day events (date DTSTART) on the server must return None from _parse_event."""
  138. now = datetime.now(timezone.utc)
  139. events = calendar.search(
  140. start=now - timedelta(days=30),
  141. end=now + timedelta(days=90),
  142. event=True,
  143. expand=False,
  144. )
  145. all_day = []
  146. for e in events:
  147. cal = iCal.from_ical(e.data)
  148. for comp in cal.walk():
  149. if comp.name == "VEVENT":
  150. dtstart = comp.get("DTSTART")
  151. if dtstart is not None and isinstance(dtstart.dt, date) and not isinstance(dtstart.dt, datetime):
  152. all_day.append(e)
  153. break
  154. if not all_day:
  155. pytest.skip("No all-day events found in the search window to test against")
  156. for e in all_day:
  157. assert _parse_event(e) is None, "Expected None for all-day event"