Files
Oxicloud/tests/caldav/test_report.py
T
2026-07-15 07:55:31 +02:00

297 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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>
"""