297 lines
11 KiB
Python
297 lines
11 KiB
Python
|
|
"""CalDAV REPORT method coverage via python-caldav.
|
|||
|
|
|
|||
|
|
The REPORT verb (RFC 4791 §7) is how clients do bulk sync + filtered
|
|||
|
|
lookup. Three subtypes matter for OxiCloud's server surface:
|
|||
|
|
|
|||
|
|
* `calendar-query` (§7.8) — filter events by time-range /
|
|||
|
|
property. `search(start=..., end=...)` in pycaldav emits this.
|
|||
|
|
* `calendar-multiget` (§7.9) — batch fetch by href list. Used
|
|||
|
|
when the client already knows which UIDs it wants.
|
|||
|
|
* `sync-collection` (§7.9 / RFC 6578) — token-based delta sync.
|
|||
|
|
Not exercised here yet — the server delegates to `list_events`
|
|||
|
|
(no per-token filtering), so a coverage test would just
|
|||
|
|
replicate the calendar-query case. Leave for later once real
|
|||
|
|
sync-token support lands.
|
|||
|
|
|
|||
|
|
The tests seed a fresh calendar with three timed events an hour
|
|||
|
|
apart, then exercise each REPORT shape. Row-count assertions are
|
|||
|
|
safe here because the seeded events are all masters (non-recurring),
|
|||
|
|
so master/exception folding doesn't apply — one URL per UID matches
|
|||
|
|
one row in DB.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import textwrap
|
|||
|
|
import uuid
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
|
|||
|
|
import caldav
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
# Helpers — mirror the pattern from test_recurring.py /
|
|||
|
|
# test_ical_coverage.py. Deliberately duplicated for now;
|
|||
|
|
# promote to conftest.py once a fourth test file shows up.
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 _seed_three_events(calendar: caldav.Calendar) -> list[str]:
|
|||
|
|
"""Seed three non-recurring events, one hour apart, starting
|
|||
|
|
2026-01-01T09:00 UTC. Returns the list of UIDs in wall-clock
|
|||
|
|
order (index 0 = earliest).
|
|||
|
|
|
|||
|
|
Non-recurring is deliberate: it isolates REPORT semantics from
|
|||
|
|
master/exception folding (which is phase-4 territory)."""
|
|||
|
|
uids: list[str] = []
|
|||
|
|
times = [
|
|||
|
|
("20260101T090000Z", "20260101T093000Z", "Morning standup"),
|
|||
|
|
("20260101T100000Z", "20260101T110000Z", "Mid-morning sync"),
|
|||
|
|
("20260101T140000Z", "20260101T150000Z", "Afternoon review"),
|
|||
|
|
]
|
|||
|
|
for start, end, summary in times:
|
|||
|
|
uid = f"report-{uuid.uuid4().hex[:8]}"
|
|||
|
|
_put_ical(
|
|||
|
|
calendar,
|
|||
|
|
uid,
|
|||
|
|
_dedent(
|
|||
|
|
f"""\
|
|||
|
|
BEGIN:VCALENDAR
|
|||
|
|
VERSION:2.0
|
|||
|
|
PRODID:-//pycaldav report coverage//EN
|
|||
|
|
BEGIN:VEVENT
|
|||
|
|
UID:{uid}
|
|||
|
|
DTSTAMP:20260101T080000Z
|
|||
|
|
DTSTART:{start}
|
|||
|
|
DTEND:{end}
|
|||
|
|
SUMMARY:{summary}
|
|||
|
|
END:VEVENT
|
|||
|
|
END:VCALENDAR
|
|||
|
|
"""
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
uids.append(uid)
|
|||
|
|
return uids
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
# calendar-query REPORT
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_calendar_query_time_range_returns_events_in_window(
|
|||
|
|
fresh_calendar: caldav.Calendar,
|
|||
|
|
) -> None:
|
|||
|
|
"""A time-range filter that spans the middle of the seeded
|
|||
|
|
day should return only the events whose (DTSTART, DTEND)
|
|||
|
|
overlaps the window. RFC 4791 §9.9 defines overlap: an event
|
|||
|
|
overlaps a range if DTSTART < range_end AND DTEND > range_start."""
|
|||
|
|
uids = _seed_three_events(fresh_calendar)
|
|||
|
|
|
|||
|
|
# Window: 09:30 → 12:00 UTC. Overlaps events 0 (09:00–09:30
|
|||
|
|
# touches the boundary at 09:30; RFC excludes exact touch)
|
|||
|
|
# and event 1 (10:00–11:00, wholly inside). Excludes event 2
|
|||
|
|
# (14:00–15:00, well outside).
|
|||
|
|
window_start = datetime(2026, 1, 1, 9, 30, tzinfo=timezone.utc)
|
|||
|
|
window_end = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|||
|
|
|
|||
|
|
found = fresh_calendar.search(
|
|||
|
|
start=window_start,
|
|||
|
|
end=window_end,
|
|||
|
|
event=True,
|
|||
|
|
expand=False,
|
|||
|
|
)
|
|||
|
|
found_uids = {_uid_from_event_data(e.data) for e in found}
|
|||
|
|
|
|||
|
|
# Event 1 (10:00–11:00) is definitely in-window; event 2 (14:00–
|
|||
|
|
# 15:00) is definitely out. Event 0's overlap is boundary-
|
|||
|
|
# dependent (server interpretation varies at exact-touch). The
|
|||
|
|
# strong invariant: event 1 in, event 2 out.
|
|||
|
|
assert uids[1] in found_uids, (
|
|||
|
|
f"Event 1 (mid-morning, wholly inside window) missing from "
|
|||
|
|
f"time-range REPORT. Got: {found_uids}"
|
|||
|
|
)
|
|||
|
|
assert uids[2] not in found_uids, (
|
|||
|
|
f"Event 2 (afternoon, wholly outside window) leaked into "
|
|||
|
|
f"time-range REPORT. Got: {found_uids}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_calendar_query_time_range_after_all_events_returns_empty(
|
|||
|
|
fresh_calendar: caldav.Calendar,
|
|||
|
|
) -> None:
|
|||
|
|
"""A window that starts after every seeded event returns
|
|||
|
|
zero results — proves the range filter is actually applied,
|
|||
|
|
not silently ignored (which would surface as "all events
|
|||
|
|
returned regardless of window")."""
|
|||
|
|
_seed_three_events(fresh_calendar)
|
|||
|
|
|
|||
|
|
window_start = datetime(2027, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|||
|
|
window_end = datetime(2027, 1, 2, 0, 0, tzinfo=timezone.utc)
|
|||
|
|
|
|||
|
|
found = fresh_calendar.search(
|
|||
|
|
start=window_start,
|
|||
|
|
end=window_end,
|
|||
|
|
event=True,
|
|||
|
|
expand=False,
|
|||
|
|
)
|
|||
|
|
assert found == [], (
|
|||
|
|
f"Expected empty result for window one year past all seeded "
|
|||
|
|
f"events; got {len(found)} entries."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_calendar_query_time_range_before_all_events_returns_empty(
|
|||
|
|
fresh_calendar: caldav.Calendar,
|
|||
|
|
) -> None:
|
|||
|
|
"""Symmetric to the after-window case."""
|
|||
|
|
_seed_three_events(fresh_calendar)
|
|||
|
|
|
|||
|
|
window_start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|||
|
|
window_end = datetime(2025, 1, 2, 0, 0, tzinfo=timezone.utc)
|
|||
|
|
|
|||
|
|
found = fresh_calendar.search(
|
|||
|
|
start=window_start,
|
|||
|
|
end=window_end,
|
|||
|
|
event=True,
|
|||
|
|
expand=False,
|
|||
|
|
)
|
|||
|
|
assert found == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_calendar_query_no_filter_returns_every_event(
|
|||
|
|
fresh_calendar: caldav.Calendar,
|
|||
|
|
) -> None:
|
|||
|
|
"""`calendar.events()` (pycaldav) issues a calendar-query without
|
|||
|
|
a time-range — the server routes this via `list_events`, so
|
|||
|
|
every event in the calendar surfaces. Row count = 3 seeded
|
|||
|
|
events (all non-recurring, so 1 URL per row)."""
|
|||
|
|
uids = _seed_three_events(fresh_calendar)
|
|||
|
|
|
|||
|
|
all_events = fresh_calendar.events()
|
|||
|
|
found_uids = {_uid_from_event_data(e.data) for e in all_events}
|
|||
|
|
|
|||
|
|
for expected in uids:
|
|||
|
|
assert expected in found_uids, (
|
|||
|
|
f"Seeded event {expected} missing from unfiltered "
|
|||
|
|
f"calendar-query REPORT. Got: {found_uids}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
# calendar-multiget REPORT
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_calendar_multiget_by_href_returns_the_targeted_events(
|
|||
|
|
fresh_calendar: caldav.Calendar,
|
|||
|
|
) -> None:
|
|||
|
|
"""calendar-multiget takes an explicit href list and returns
|
|||
|
|
exactly those. Two hrefs → two responses. The server's
|
|||
|
|
`get_events_by_ical_uids` (indexed `ical_uid = ANY(...)`) is
|
|||
|
|
what pays for this instead of listing the whole calendar."""
|
|||
|
|
uids = _seed_three_events(fresh_calendar)
|
|||
|
|
|
|||
|
|
base = str(fresh_calendar.url).rstrip("/") + "/"
|
|||
|
|
# Target the first two events; skip event 2.
|
|||
|
|
hrefs = [f"{base}{uids[0]}.ics", f"{base}{uids[1]}.ics"]
|
|||
|
|
xml = _multiget_body(hrefs)
|
|||
|
|
|
|||
|
|
r = fresh_calendar.client.request(
|
|||
|
|
str(fresh_calendar.url),
|
|||
|
|
method="REPORT",
|
|||
|
|
body=xml,
|
|||
|
|
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
|
|||
|
|
)
|
|||
|
|
assert 200 <= r.status < 300, (
|
|||
|
|
f"REPORT calendar-multiget → HTTP {r.status}\nbody: {r.raw!r}"
|
|||
|
|
)
|
|||
|
|
xml_body = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
|
|||
|
|
|
|||
|
|
assert uids[0] in xml_body, (
|
|||
|
|
f"Requested UID {uids[0]} missing from multiget response."
|
|||
|
|
)
|
|||
|
|
assert uids[1] in xml_body, (
|
|||
|
|
f"Requested UID {uids[1]} missing from multiget response."
|
|||
|
|
)
|
|||
|
|
assert uids[2] not in xml_body, (
|
|||
|
|
f"UID {uids[2]} (not requested) leaked into multiget response."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_calendar_multiget_unknown_href_is_silently_absent(
|
|||
|
|
fresh_calendar: caldav.Calendar,
|
|||
|
|
) -> None:
|
|||
|
|
"""CalDAV multiget semantics: a requested href that doesn't
|
|||
|
|
exist is silently absent from the response (not an error).
|
|||
|
|
Some servers emit a `<D:status>404</D:status>` per-href entry;
|
|||
|
|
the minimum bar is that the server must NOT 500 and must NOT
|
|||
|
|
invent data."""
|
|||
|
|
uids = _seed_three_events(fresh_calendar)
|
|||
|
|
|
|||
|
|
base = str(fresh_calendar.url).rstrip("/") + "/"
|
|||
|
|
ghost_uid = f"does-not-exist-{uuid.uuid4().hex[:8]}"
|
|||
|
|
hrefs = [f"{base}{uids[0]}.ics", f"{base}{ghost_uid}.ics"]
|
|||
|
|
|
|||
|
|
r = fresh_calendar.client.request(
|
|||
|
|
str(fresh_calendar.url),
|
|||
|
|
method="REPORT",
|
|||
|
|
body=_multiget_body(hrefs),
|
|||
|
|
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
|
|||
|
|
)
|
|||
|
|
assert 200 <= r.status < 300, (
|
|||
|
|
f"REPORT multiget with an unknown href must not 500 — got "
|
|||
|
|
f"HTTP {r.status}\nresponse: {r.raw!r}"
|
|||
|
|
)
|
|||
|
|
xml_body = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
|
|||
|
|
assert uids[0] in xml_body, (
|
|||
|
|
"Existing UID missing from multiget that also targeted a ghost href."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
# Low-level helpers
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _uid_from_event_data(data: str) -> str | None:
|
|||
|
|
"""Pull the UID out of a raw iCalendar body. Cheap enough for a
|
|||
|
|
handful of events per test."""
|
|||
|
|
for line in data.replace("\r\n", "\n").split("\n"):
|
|||
|
|
if line.startswith("UID:"):
|
|||
|
|
return line[4:].strip()
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _multiget_body(hrefs: list[str]) -> str:
|
|||
|
|
"""Assemble a minimal RFC 4791 §7.9 calendar-multiget REPORT
|
|||
|
|
XML body for the given href list."""
|
|||
|
|
href_xml = "\n ".join(f"<D:href>{h}</D:href>" for h in hrefs)
|
|||
|
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|||
|
|
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|||
|
|
<D:prop>
|
|||
|
|
<D:getetag/>
|
|||
|
|
<C:calendar-data/>
|
|||
|
|
</D:prop>
|
|||
|
|
{href_xml}
|
|||
|
|
</C:calendar-multiget>
|
|||
|
|
"""
|