test: full caldav + carddav test suite
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user