123 lines
4.8 KiB
Python
123 lines
4.8 KiB
Python
"""CalDAV client pure helpers — XML multistatus / REPORT parsing + ICS round-trip."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
|
||
|
|
from webmail_plugin_calendar.caldav import (
|
||
|
|
build_ics,
|
||
|
|
event_href,
|
||
|
|
parse_href,
|
||
|
|
parse_multistatus_calendars,
|
||
|
|
parse_report,
|
||
|
|
vevent_to_fields,
|
||
|
|
)
|
||
|
|
|
||
|
|
_PRINCIPAL_XML = """<?xml version="1.0"?>
|
||
|
|
<d:multistatus xmlns:d="DAV:">
|
||
|
|
<d:response><d:href>/</d:href><d:propstat><d:status>HTTP/1.1 200 OK</d:status>
|
||
|
|
<d:prop><d:current-user-principal><d:href>/123/principal/</d:href></d:current-user-principal></d:prop>
|
||
|
|
</d:propstat></d:response>
|
||
|
|
</d:multistatus>"""
|
||
|
|
|
||
|
|
_CAL_LIST_XML = """<?xml version="1.0"?>
|
||
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"
|
||
|
|
xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
||
|
|
<d:response><d:href>/123/calendars/home/</d:href><d:propstat><d:status>HTTP/1.1 200 OK</d:status>
|
||
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||
|
|
<d:response><d:href>/123/calendars/work/</d:href><d:propstat><d:status>HTTP/1.1 200 OK</d:status>
|
||
|
|
<d:prop>
|
||
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||
|
|
<d:displayname>Work</d:displayname>
|
||
|
|
<ical:calendar-color>#ff0000ff</ical:calendar-color>
|
||
|
|
<cs:getctag>ctag-42</cs:getctag>
|
||
|
|
<d:current-user-privilege-set><d:privilege><d:write/></d:privilege></d:current-user-privilege-set>
|
||
|
|
<c:supported-calendar-component-set>
|
||
|
|
<c:comp name="VEVENT"/></c:supported-calendar-component-set>
|
||
|
|
</d:prop></d:propstat></d:response>
|
||
|
|
<d:response><d:href>/123/calendars/holidays/</d:href><d:propstat>
|
||
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||
|
|
<d:prop>
|
||
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||
|
|
<d:displayname>Holidays</d:displayname>
|
||
|
|
<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>
|
||
|
|
<c:supported-calendar-component-set>
|
||
|
|
<c:comp name="VEVENT"/></c:supported-calendar-component-set>
|
||
|
|
</d:prop></d:propstat></d:response>
|
||
|
|
</d:multistatus>"""
|
||
|
|
|
||
|
|
_REPORT_XML = """<?xml version="1.0"?>
|
||
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||
|
|
<d:response><d:href>/123/calendars/work/ev1.ics</d:href><d:propstat>
|
||
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||
|
|
<d:prop><d:getetag>"etag-1"</d:getetag>
|
||
|
|
<c:calendar-data>BEGIN:VCALENDAR
|
||
|
|
VERSION:2.0
|
||
|
|
BEGIN:VEVENT
|
||
|
|
UID:ev1@example.com
|
||
|
|
SUMMARY:Standup
|
||
|
|
DTSTART:20260810T090000Z
|
||
|
|
DTEND:20260810T091500Z
|
||
|
|
END:VEVENT
|
||
|
|
END:VCALENDAR</c:calendar-data></d:prop></d:propstat></d:response>
|
||
|
|
</d:multistatus>"""
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_href_principal() -> None:
|
||
|
|
assert parse_href(_PRINCIPAL_XML, "{DAV:}current-user-principal") == "/123/principal/"
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_calendar_list_filters_and_reads_props() -> None:
|
||
|
|
cals = parse_multistatus_calendars(_CAL_LIST_XML, "https://dav.example.com/123/calendars/")
|
||
|
|
# The home collection (no <calendar> resourcetype) is skipped; two calendars remain.
|
||
|
|
by_name = {c.name: c for c in cals}
|
||
|
|
assert set(by_name) == {"Work", "Holidays"}
|
||
|
|
work = by_name["Work"]
|
||
|
|
assert work.url == "https://dav.example.com/123/calendars/work/"
|
||
|
|
assert work.color == "#ff0000" # trimmed to #rrggbb
|
||
|
|
assert work.ctag == "ctag-42"
|
||
|
|
assert work.read_only is False # has <write>
|
||
|
|
assert by_name["Holidays"].read_only is True # read privilege only
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_report_events() -> None:
|
||
|
|
events = parse_report(_REPORT_XML, "https://dav.example.com/123/calendars/work/")
|
||
|
|
assert len(events) == 1
|
||
|
|
ev = events[0]
|
||
|
|
assert ev.href == "https://dav.example.com/123/calendars/work/ev1.ics"
|
||
|
|
assert ev.etag == "etag-1" # quotes stripped
|
||
|
|
fields = vevent_to_fields(ev.ics)
|
||
|
|
assert fields is not None
|
||
|
|
assert fields["ical_uid"] == "ev1@example.com"
|
||
|
|
assert fields["summary"] == "Standup"
|
||
|
|
assert fields["dtstart"] == datetime(2026, 8, 10, 9, 0, tzinfo=UTC)
|
||
|
|
assert fields["all_day"] is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_build_ics_roundtrips() -> None:
|
||
|
|
ics = build_ics(
|
||
|
|
{
|
||
|
|
"ical_uid": "made@ultimo",
|
||
|
|
"summary": "Lunch",
|
||
|
|
"location": "Cafe",
|
||
|
|
"dtstart": datetime(2026, 8, 11, 12, 0, tzinfo=UTC),
|
||
|
|
"dtend": datetime(2026, 8, 11, 13, 0, tzinfo=UTC),
|
||
|
|
"all_day": False,
|
||
|
|
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
fields = vevent_to_fields(ics)
|
||
|
|
assert fields is not None
|
||
|
|
assert fields["ical_uid"] == "made@ultimo"
|
||
|
|
assert fields["summary"] == "Lunch"
|
||
|
|
assert fields["location"] == "Cafe"
|
||
|
|
assert fields["rrule"] == "FREQ=WEEKLY;BYDAY=MO"
|
||
|
|
assert fields["dtstart"] == datetime(2026, 8, 11, 12, 0, tzinfo=UTC)
|
||
|
|
|
||
|
|
|
||
|
|
def test_event_href() -> None:
|
||
|
|
assert (
|
||
|
|
event_href("https://dav.example.com/cal/work", "abc@ex")
|
||
|
|
== "https://dav.example.com/cal/work/abc@ex.ics"
|
||
|
|
)
|