Files
Oxicloud/tests/caldav/test_ical_coverage.py
T
2026-07-14 22:01:31 +02:00

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