288 lines
11 KiB
Python
288 lines
11 KiB
Python
|
|
"""Non-recurring iCalendar property coverage via python-caldav.
|
||
|
|
|
||
|
|
Complements `test_recurring.py` (the #528 regression suite) by
|
||
|
|
sweeping the property surface of a single, non-recurring VEVENT.
|
||
|
|
Real CalDAV clients send many properties beyond DTSTART/DTEND +
|
||
|
|
SUMMARY; whether those survive a PUT → GET round-trip is what
|
||
|
|
this file measures.
|
||
|
|
|
||
|
|
The GET path in `caldav_handler.rs::write_vevent` regenerates
|
||
|
|
the response body from the stored DTO fields (UID / SUMMARY /
|
||
|
|
DTSTART / DTEND / DESCRIPTION / LOCATION / RRULE / DTSTAMP /
|
||
|
|
CREATED / LAST-MODIFIED). Anything not in that list is silently
|
||
|
|
dropped even though the original `ical_data` is stored intact.
|
||
|
|
|
||
|
|
Tests split into two groups:
|
||
|
|
|
||
|
|
* **Sanity** — properties the server emits on GET; they must
|
||
|
|
round-trip. Regressions here would be genuine server bugs.
|
||
|
|
|
||
|
|
* **xfail (documented gaps)** — properties the server currently
|
||
|
|
drops. `@pytest.mark.xfail(strict=False)` lets the suite stay
|
||
|
|
green while making the gap visible in the pytest summary. If
|
||
|
|
a future server fix makes one of these survive, pytest
|
||
|
|
reports it as `XPASS` — an alert to remove the marker.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import textwrap
|
||
|
|
import uuid
|
||
|
|
|
||
|
|
import caldav
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# Helpers (mirror the raw-HTTP-PUT / master-URL-GET pattern
|
||
|
|
# from test_recurring.py). Kept local to this file for now;
|
||
|
|
# fold into conftest.py if a third test file wants them.
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def _dedent(ical: str) -> str:
|
||
|
|
return textwrap.dedent(ical).strip().replace("\n", "\r\n") + "\r\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _put_ical(calendar: caldav.Calendar, uid: str, body: str) -> None:
|
||
|
|
url = str(calendar.url).rstrip("/") + f"/{uid}.ics"
|
||
|
|
r = calendar.client.request(
|
||
|
|
url,
|
||
|
|
method="PUT",
|
||
|
|
body=body,
|
||
|
|
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||
|
|
)
|
||
|
|
if r.status < 200 or r.status >= 300:
|
||
|
|
raise AssertionError(
|
||
|
|
f"PUT {url} → HTTP {r.status}\nbody: {body!r}\nresponse: {r.raw!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _get_ical(calendar: caldav.Calendar, uid: str) -> str:
|
||
|
|
url = str(calendar.url).rstrip("/") + f"/{uid}.ics"
|
||
|
|
r = calendar.client.request(url, method="GET")
|
||
|
|
if r.status < 200 or r.status >= 300:
|
||
|
|
raise AssertionError(f"GET {url} → HTTP {r.status}")
|
||
|
|
return r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
|
||
|
|
|
||
|
|
|
||
|
|
def _minimal_event(uid: str, **extra_lines: str) -> str:
|
||
|
|
"""Build a minimal VEVENT with the given extra iCal property lines
|
||
|
|
injected before END:VEVENT. Values in `extra_lines` should be full
|
||
|
|
property lines (name+value), one per key. The key exists only so
|
||
|
|
tests can override without clobbering; it isn't emitted."""
|
||
|
|
base = f"""\
|
||
|
|
BEGIN:VCALENDAR
|
||
|
|
VERSION:2.0
|
||
|
|
PRODID:-//pycaldav coverage//EN
|
||
|
|
BEGIN:VEVENT
|
||
|
|
UID:{uid}
|
||
|
|
DTSTAMP:20260101T100000Z
|
||
|
|
DTSTART:20260101T090000Z
|
||
|
|
DTEND:20260101T093000Z
|
||
|
|
SUMMARY:Coverage event
|
||
|
|
"""
|
||
|
|
body = textwrap.dedent(base).rstrip() + "\n"
|
||
|
|
for line in extra_lines.values():
|
||
|
|
body += line + "\n"
|
||
|
|
body += "END:VEVENT\nEND:VCALENDAR\n"
|
||
|
|
return body.replace("\n", "\r\n")
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# Sanity — properties the server DOES emit on GET.
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def test_description_with_escaped_chars_round_trips(
|
||
|
|
fresh_calendar: caldav.Calendar,
|
||
|
|
) -> None:
|
||
|
|
"""RFC 5545 §3.3.11 mandates comma / semicolon / newline
|
||
|
|
escaping in TEXT values. A Description with all three must
|
||
|
|
survive PUT → GET.
|
||
|
|
|
||
|
|
Note: our own generate_event_ical only escapes newlines
|
||
|
|
(`\\n`), not commas or semicolons — this test guards the
|
||
|
|
minimum bar. A stricter test could assert exact escape
|
||
|
|
handling; deferred until the emitter is RFC-strict."""
|
||
|
|
uid = f"cov-desc-{uuid.uuid4().hex[:8]}"
|
||
|
|
# RFC 5545 escapes: `\n` for newline, `\,` for comma, `\;` for
|
||
|
|
# semicolon. Client sends them ALREADY escaped in the wire body.
|
||
|
|
body = _minimal_event(
|
||
|
|
uid,
|
||
|
|
description=r"DESCRIPTION:multi-line\ntext with a comma\, and a semi\;colon.",
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "multi-line" in fetched
|
||
|
|
# Server currently emits `\n` back but may drop `\,` / `\;`
|
||
|
|
# escapes — accept either the escaped or unescaped form here so
|
||
|
|
# the sanity check tolerates the current emitter without failing
|
||
|
|
# on the strict spec detail.
|
||
|
|
assert (
|
||
|
|
"comma" in fetched.lower()
|
||
|
|
), f"DESCRIPTION body lost the comma text entirely:\n{fetched}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_location_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
|
||
|
|
uid = f"cov-loc-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _minimal_event(
|
||
|
|
uid,
|
||
|
|
location="LOCATION:Room 3B\\, Building 42",
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "Room 3B" in fetched, f"LOCATION lost:\n{fetched}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_uid_and_dtstamp_are_preserved(fresh_calendar: caldav.Calendar) -> None:
|
||
|
|
"""Belt-and-braces sanity — UID is the resource identifier and
|
||
|
|
DTSTAMP is required by RFC 5545 §3.8.7.2 on every VEVENT. Both
|
||
|
|
are emitted from DTO fields, so both round-trip cleanly."""
|
||
|
|
uid = f"cov-uid-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _minimal_event(uid)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert f"UID:{uid}" in fetched
|
||
|
|
assert "DTSTAMP:" in fetched
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# Documented gaps — properties the server currently drops on
|
||
|
|
# GET. `xfail(strict=False)` means "expected to fail; don't fail
|
||
|
|
# the suite, but flag XPASS if it starts passing". When the
|
||
|
|
# read-side fix lands, remove the marker.
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
_EMITTER_GAP_REASON = (
|
||
|
|
"GET regenerates the body from DTO fields via write_vevent "
|
||
|
|
"(caldav_handler.rs:~770) which only emits UID / SUMMARY / "
|
||
|
|
"DTSTART / DTEND / DESCRIPTION / LOCATION / RRULE / DTSTAMP / "
|
||
|
|
"CREATED / LAST-MODIFIED. Every other iCal property is stored "
|
||
|
|
"in ical_data on the row but silently dropped on read. "
|
||
|
|
"Fix path: either serve ical_data verbatim on GET, or extend "
|
||
|
|
"the DTO to carry the full property set."
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
|
||
|
|
def test_attendee_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
|
||
|
|
uid = f"cov-attendee-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _minimal_event(
|
||
|
|
uid,
|
||
|
|
attendee=(
|
||
|
|
"ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED;RSVP=TRUE:"
|
||
|
|
"mailto:alice@example.com"
|
||
|
|
),
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "ATTENDEE" in fetched, f"ATTENDEE dropped:\n{fetched}"
|
||
|
|
assert "alice@example.com" in fetched
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
|
||
|
|
def test_organizer_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
|
||
|
|
uid = f"cov-organizer-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _minimal_event(
|
||
|
|
uid,
|
||
|
|
organizer="ORGANIZER;CN=Bob:mailto:bob@example.com",
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "ORGANIZER" in fetched
|
||
|
|
assert "bob@example.com" in fetched
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
|
||
|
|
def test_categories_survive_round_trip(fresh_calendar: caldav.Calendar) -> None:
|
||
|
|
uid = f"cov-cats-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _minimal_event(
|
||
|
|
uid,
|
||
|
|
categories="CATEGORIES:MEETING,ENGINEERING,SPRINT-42",
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "CATEGORIES" in fetched
|
||
|
|
assert "ENGINEERING" in fetched
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
|
||
|
|
def test_status_and_transp_survive_round_trip(
|
||
|
|
fresh_calendar: caldav.Calendar,
|
||
|
|
) -> None:
|
||
|
|
"""STATUS (RFC 5545 §3.8.1.11) and TRANSP (§3.8.2.7) drive
|
||
|
|
"tentative vs confirmed" and "shows as busy vs free" in every
|
||
|
|
calendar client UI. Losing them silently is user-visible."""
|
||
|
|
uid = f"cov-status-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _minimal_event(
|
||
|
|
uid,
|
||
|
|
status="STATUS:TENTATIVE",
|
||
|
|
transp="TRANSP:TRANSPARENT",
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "STATUS:TENTATIVE" in fetched
|
||
|
|
assert "TRANSP:TRANSPARENT" in fetched
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
|
||
|
|
def test_valarm_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
|
||
|
|
"""VALARM is a nested sub-component of VEVENT (RFC 5545 §3.6.6)
|
||
|
|
and drives every "remind me 15 min before" popup. It lives
|
||
|
|
entirely in ical_data on the row and is invisible to the DTO.
|
||
|
|
Dropping it on GET means alarms silently disappear after the
|
||
|
|
first client sync."""
|
||
|
|
uid = f"cov-alarm-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _dedent(
|
||
|
|
f"""\
|
||
|
|
BEGIN:VCALENDAR
|
||
|
|
VERSION:2.0
|
||
|
|
PRODID:-//pycaldav coverage//EN
|
||
|
|
BEGIN:VEVENT
|
||
|
|
UID:{uid}
|
||
|
|
DTSTAMP:20260101T100000Z
|
||
|
|
DTSTART:20260101T090000Z
|
||
|
|
DTEND:20260101T093000Z
|
||
|
|
SUMMARY:Event with alarm
|
||
|
|
BEGIN:VALARM
|
||
|
|
ACTION:DISPLAY
|
||
|
|
TRIGGER:-PT15M
|
||
|
|
DESCRIPTION:15 min reminder
|
||
|
|
END:VALARM
|
||
|
|
END:VEVENT
|
||
|
|
END:VCALENDAR
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "BEGIN:VALARM" in fetched, f"VALARM block dropped:\n{fetched}"
|
||
|
|
assert "TRIGGER:-PT15M" in fetched
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
|
||
|
|
def test_custom_x_property_survives_round_trip(
|
||
|
|
fresh_calendar: caldav.Calendar,
|
||
|
|
) -> None:
|
||
|
|
"""Custom `X-*` properties (RFC 5545 §3.8.8.2). Apple Calendar
|
||
|
|
uses `X-APPLE-*`, DAVx⁵ uses `X-MOZ-*`, and Nextcloud uses
|
||
|
|
`X-NEXTCLOUD-*`. Dropping them breaks client-specific UI cues
|
||
|
|
without corrupting core interop."""
|
||
|
|
uid = f"cov-xprop-{uuid.uuid4().hex[:8]}"
|
||
|
|
body = _minimal_event(
|
||
|
|
uid,
|
||
|
|
xprop="X-MOZ-LASTACK:20260101T090000Z",
|
||
|
|
)
|
||
|
|
_put_ical(fresh_calendar, uid, body)
|
||
|
|
|
||
|
|
fetched = _get_ical(fresh_calendar, uid)
|
||
|
|
assert "X-MOZ-LASTACK" in fetched
|