cb6c29a063
keep information of: ATTENDEE, ORGANIZER, CATEGORIES, STATUS, TRANSP, VALARM, X-*
this fix answer in all calldav GET
271 lines
10 KiB
Python
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
|