Files
Oxicloud/tests/caldav/test_ical_coverage.py
Edouard Vanbelle cb6c29a063 fix(caldav): fix generation of events
keep information of: ATTENDEE, ORGANIZER, CATEGORIES, STATUS, TRANSP, VALARM, X-*

    this fix answer in all calldav GET
2026-07-15 07:55:31 +02:00

271 lines
10 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.
Every test is a strict round-trip pin: PUT a vCalendar body
carrying the property, GET the URL, assert the property is
present in the response. Post-phase-4 the emitter serves each
row's stored `ical_data` verbatim (folded per UID), so a
regression on any property here means either the storage
layer stopped preserving ical_data OR the emitter reverted
to DTO-field regeneration.
"""
from __future__ import annotations
import textwrap
import uuid
import caldav
# ─────────────────────────────────────────────────────────────
# 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
# ─────────────────────────────────────────────────────────────
# Extended round-trips — properties beyond the DTO-structured
# columns. Post-phase-4 the emitter serves each row's stored
# `ical_data` verbatim (folded per UID), so ATTENDEE, ORGANIZER,
# CATEGORIES, STATUS+TRANSP, VALARM (nested), custom X-* all
# survive PUT → GET. A regression on any of these means either
# storage stopped preserving ical_data OR the emitter reverted
# to DTO regeneration.
# ─────────────────────────────────────────────────────────────
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
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
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
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
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
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