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>
|
||
"""
|