fix(calendar): preserve floating/TZID semantics across CalDAV roundtrip (#782)
The CalDAV REPORT in `_search_events_by_date` unconditionally requested server-side `<C:expand>`. Per RFC 4791 §9.6.5 the server then normalizes every expanded DTSTART/DTEND to UTC `Z`, which destroyed two pieces of information on the read path: - RFC 5545 floating local times came back as fake-UTC (a `+00:00` suffix that did not match the stored value), so a 2:30 PM floating event was indistinguishable from a 14:30 UTC event in the MCP response. - TZID-bound events lost their IANA TZID context — a "10am America/New_York" event came back as `14:00:00+00:00`, making it impossible for callers to reconstruct DST-aware recurrence semantics. Replace `<C:expand>` with client-side recurrence expansion via the `recurring-ical-events` library (promoted from transitive to direct dep), so the wire response retains its original DTSTART format. Surface the TZID parameter as new `start_tz`/`end_tz` fields on `CalendarEventSummary`. Add an optional `timezone` (IANA name) parameter to `nc_calendar_create_event` and `nc_calendar_update_event` so callers can pin a TZID for naive input; the helper attaches `ZoneInfo(...)` and emits a paired `VTIMEZONE` component. Naive input without `timezone` continues to store as RFC 5545 floating local time (with a warning logged). Offset-aware input continues to store as UTC `Z`. Drive-by: switch the update path's DTSTART/DTEND assignment from raw `datetime` to `vDDDTypes(dt)` wrappers — the previous code produced invalid iCal like `DTSTART:2026-05-14 10:00:00+00:00` for any TZ-aware update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
728a9ae252
commit
9d5ac01f24
@@ -5,12 +5,14 @@ import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import anyio
|
||||
import recurring_ical_events
|
||||
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
|
||||
from caldav.elements import cdav, dav
|
||||
from caldav.lib import error as caldav_error
|
||||
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
||||
from icalendar import Alarm, Calendar, Timezone, vDDDTypes, vRecur
|
||||
from icalendar import Event as ICalEvent
|
||||
from icalendar import Todo as ICalTodo
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
@@ -320,49 +322,101 @@ class CalendarClient:
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
if start_datetime or end_datetime:
|
||||
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||
events = await self._search_events_by_date(
|
||||
calendar, start_datetime, end_datetime
|
||||
)
|
||||
# Expand is only used when both bounds are provided
|
||||
expanded = bool(start_datetime and end_datetime)
|
||||
# Client-side recurrence expansion preserves DTSTART format
|
||||
# (floating / TZID / UTC). RFC 4791 <C:expand> would normalize
|
||||
# everything to UTC and erase the original timezone context.
|
||||
do_expand = bool(start_datetime and end_datetime)
|
||||
else:
|
||||
# No date filter — fetch all events
|
||||
events = await calendar.events() # type: ignore[misc] # dual-mode
|
||||
expanded = False
|
||||
do_expand = False
|
||||
|
||||
result = []
|
||||
for event in events:
|
||||
await _maybe_await(event.load(only_if_unloaded=True))
|
||||
if event.data:
|
||||
if expanded:
|
||||
# Server-side expansion: each response resource may contain
|
||||
# multiple VEVENTs (one per recurrence occurrence)
|
||||
for event_dict in self._parse_all_ical_events(event.data):
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
else:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
if not event.data:
|
||||
continue
|
||||
|
||||
try:
|
||||
cal = Calendar.from_ical(event.data)
|
||||
except Exception as e:
|
||||
logger.error("Error parsing iCalendar event: %s", e)
|
||||
continue
|
||||
|
||||
href = str(event.url)
|
||||
event_dicts = self._expand_event_occurrences(
|
||||
cal, start_datetime, end_datetime, do_expand
|
||||
)
|
||||
for event_dict in event_dicts:
|
||||
event_dict["href"] = href
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
|
||||
if len(result) >= limit:
|
||||
break
|
||||
|
||||
if len(result) >= limit:
|
||||
break
|
||||
|
||||
logger.debug(f"Found {len(result)} events")
|
||||
logger.debug("Found %d events", len(result))
|
||||
return result
|
||||
|
||||
def _expand_event_occurrences(
|
||||
self,
|
||||
cal: Any,
|
||||
start_datetime: dt.datetime | None,
|
||||
end_datetime: dt.datetime | None,
|
||||
do_expand: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return one event dict per occurrence in [start, end), or one dict for the master VEVENT.
|
||||
|
||||
When ``do_expand`` is true and the resource has an RRULE, expand recurrences
|
||||
client-side using ``recurring_ical_events`` so that TZID and floating-local
|
||||
semantics are preserved on the wire (server-side ``<C:expand>`` would
|
||||
UTC-normalize every DTSTART per RFC 4791 §9.6.5).
|
||||
"""
|
||||
if not do_expand:
|
||||
for component in cal.walk("VEVENT"):
|
||||
return [self._extract_vevent_data(component)]
|
||||
return []
|
||||
|
||||
has_rrule = any("rrule" in component for component in cal.walk("VEVENT"))
|
||||
if not has_rrule:
|
||||
for component in cal.walk("VEVENT"):
|
||||
return [self._extract_vevent_data(component)]
|
||||
return []
|
||||
|
||||
try:
|
||||
assert start_datetime is not None and end_datetime is not None
|
||||
occurrences = recurring_ical_events.of(cal).between(
|
||||
start_datetime, end_datetime
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Client-side recurrence expansion failed (%s); returning master event",
|
||||
e,
|
||||
)
|
||||
return [
|
||||
self._extract_vevent_data(component) for component in cal.walk("VEVENT")
|
||||
]
|
||||
|
||||
return [self._extract_vevent_data(occ) for occ in occurrences]
|
||||
|
||||
async def _search_events_by_date(
|
||||
self,
|
||||
calendar: AsyncCalendar,
|
||||
start_datetime: dt.datetime | None = None,
|
||||
end_datetime: dt.datetime | None = None,
|
||||
) -> list:
|
||||
"""Execute a CalDAV REPORT with time-range filter."""
|
||||
# Ensure naive datetimes are treated as UTC
|
||||
"""Execute a CalDAV REPORT with time-range filter.
|
||||
|
||||
Returns raw VEVENT resources (no server-side ``<C:expand>``). The caller
|
||||
is responsible for expanding recurring events client-side so that
|
||||
TZID/floating semantics are preserved.
|
||||
"""
|
||||
# Ensure naive datetimes are treated as UTC for the wire-level filter
|
||||
if start_datetime and start_datetime.tzinfo is None:
|
||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||
if end_datetime and end_datetime.tzinfo is None:
|
||||
@@ -374,13 +428,7 @@ class CalendarClient:
|
||||
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||
filter_element = cdav.Filter() + outer_comp_filter
|
||||
|
||||
# When both bounds are provided, request server-side expansion of
|
||||
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
|
||||
# a separate VEVENT with its own DTSTART, with RRULE stripped.
|
||||
data = cdav.CalendarData()
|
||||
if start_datetime and end_datetime:
|
||||
data += cdav.Expand(start_datetime, end_datetime)
|
||||
|
||||
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||
|
||||
body = etree.tostring(
|
||||
@@ -681,6 +729,51 @@ class CalendarClient:
|
||||
|
||||
# ============= Helper Methods - Event iCalendar =============
|
||||
|
||||
@staticmethod
|
||||
def _resolve_timezone(tz_name: str) -> ZoneInfo | None:
|
||||
"""Resolve an IANA timezone name to ZoneInfo, returning None for invalid input."""
|
||||
if not tz_name:
|
||||
return None
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
logger.warning(
|
||||
"Unknown IANA timezone %r — falling back to floating local time",
|
||||
tz_name,
|
||||
)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _parse_event_datetime(
|
||||
cls, dt_str: str, tz_name: str | None = None
|
||||
) -> tuple[dt.datetime, ZoneInfo | None]:
|
||||
"""Parse an ISO datetime string with optional TZID application.
|
||||
|
||||
Returns ``(parsed_dt, applied_zoneinfo)`` where ``applied_zoneinfo``
|
||||
is non-None only when ``tz_name`` was applied to a naive input — the
|
||||
caller uses this to know whether to emit a VTIMEZONE component.
|
||||
"""
|
||||
parsed = dt.datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||
zi = cls._resolve_timezone(tz_name) if tz_name else None
|
||||
|
||||
if parsed.tzinfo is not None:
|
||||
if zi is not None:
|
||||
logger.warning(
|
||||
"Datetime %r has an explicit offset; ignoring timezone=%r",
|
||||
dt_str,
|
||||
tz_name,
|
||||
)
|
||||
return parsed, None
|
||||
|
||||
if zi is not None:
|
||||
return parsed.replace(tzinfo=zi), zi
|
||||
|
||||
logger.warning(
|
||||
"Datetime %r is naive and no timezone was supplied — storing as RFC 5545 floating local time",
|
||||
dt_str,
|
||||
)
|
||||
return parsed, None
|
||||
|
||||
def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str:
|
||||
"""Create iCalendar content from event data."""
|
||||
cal = Calendar()
|
||||
@@ -697,6 +790,8 @@ class CalendarClient:
|
||||
start_str = event_data.get("start_datetime", "")
|
||||
end_str = event_data.get("end_datetime", "")
|
||||
all_day = event_data.get("all_day", False)
|
||||
tz_name = event_data.get("timezone", "")
|
||||
used_timezones: set[ZoneInfo] = set()
|
||||
|
||||
if start_str:
|
||||
if all_day:
|
||||
@@ -706,10 +801,14 @@ class CalendarClient:
|
||||
end_date = dt.datetime.fromisoformat(end_str.split("T")[0]).date()
|
||||
event.add("dtend", end_date)
|
||||
else:
|
||||
start_dt = dt.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
||||
start_dt, zi = self._parse_event_datetime(start_str, tz_name)
|
||||
if zi is not None:
|
||||
used_timezones.add(zi)
|
||||
event.add("dtstart", start_dt)
|
||||
if end_str:
|
||||
end_dt = dt.datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||
end_dt, zi = self._parse_event_datetime(end_str, tz_name)
|
||||
if zi is not None:
|
||||
used_timezones.add(zi)
|
||||
event.add("dtend", end_dt)
|
||||
|
||||
# Add categories
|
||||
@@ -762,14 +861,14 @@ class CalendarClient:
|
||||
event.add("dtstamp", now)
|
||||
event.add("last-modified", now)
|
||||
|
||||
# VTIMEZONE must appear before the referencing VEVENT.
|
||||
for zi in used_timezones:
|
||||
cal.add_component(Timezone.from_tzinfo(zi))
|
||||
cal.add_component(event)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
def _extract_vevent_data(self, component) -> dict[str, Any]:
|
||||
"""Extract event data from a single VEVENT component.
|
||||
|
||||
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
||||
"""
|
||||
"""Extract event data from a single VEVENT component."""
|
||||
event_data: dict[str, Any] = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
@@ -781,24 +880,28 @@ class CalendarClient:
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
# Handle dates. The ``.isoformat()`` representation already encodes the
|
||||
# storage semantics: no suffix for floating local, ``+00:00`` for UTC,
|
||||
# and the offset (e.g. ``-04:00``) for TZID-bound datetimes. The IANA
|
||||
# TZID name is surfaced separately as ``start_tz``/``end_tz`` so callers
|
||||
# can distinguish "10am NY time" (recurs in local time across DST) from
|
||||
# "14:00 UTC" (same UTC instant), which the offset alone cannot express.
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
)
|
||||
tzid = dtstart.params.get("TZID") if dtstart.params else None
|
||||
if tzid:
|
||||
event_data["start_tz"] = str(tzid)
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
tzid = dtend.params.get("TZID") if dtend.params else None
|
||||
if tzid:
|
||||
event_data["end_tz"] = str(tzid)
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
@@ -835,22 +938,6 @@ class CalendarClient:
|
||||
logger.error(f"Error parsing iCalendar event: {e}")
|
||||
return None
|
||||
|
||||
def _parse_all_ical_events(self, ical_text: str) -> list[dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract ALL event occurrences.
|
||||
|
||||
Used with server-side expansion where a single VCALENDAR contains
|
||||
multiple VEVENT components (one per recurrence occurrence).
|
||||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
results.append(self._extract_vevent_data(component))
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar events: {e}")
|
||||
return results
|
||||
|
||||
def _merge_ical_properties(
|
||||
self, raw_ical: str, event_data: dict[str, Any], event_uid: str
|
||||
) -> str:
|
||||
@@ -921,6 +1008,8 @@ class CalendarClient:
|
||||
component.add_component(alarm)
|
||||
|
||||
# Handle dates
|
||||
tz_name = event_data.get("timezone", "")
|
||||
used_timezones: set[ZoneInfo] = set()
|
||||
if "start_datetime" in event_data:
|
||||
start_str = event_data["start_datetime"]
|
||||
all_day = event_data.get("all_day", False)
|
||||
@@ -930,10 +1019,12 @@ class CalendarClient:
|
||||
).date()
|
||||
component["DTSTART"] = start_date
|
||||
else:
|
||||
start_dt = dt.datetime.fromisoformat(
|
||||
start_str.replace("Z", "+00:00")
|
||||
start_dt, zi = self._parse_event_datetime(
|
||||
start_str, tz_name
|
||||
)
|
||||
component["DTSTART"] = start_dt
|
||||
if zi is not None:
|
||||
used_timezones.add(zi)
|
||||
component["DTSTART"] = vDDDTypes(start_dt)
|
||||
|
||||
if "end_datetime" in event_data:
|
||||
end_str = event_data["end_datetime"]
|
||||
@@ -944,16 +1035,26 @@ class CalendarClient:
|
||||
).date()
|
||||
component["DTEND"] = end_date
|
||||
else:
|
||||
end_dt = dt.datetime.fromisoformat(
|
||||
end_str.replace("Z", "+00:00")
|
||||
)
|
||||
component["DTEND"] = end_dt
|
||||
end_dt, zi = self._parse_event_datetime(end_str, tz_name)
|
||||
if zi is not None:
|
||||
used_timezones.add(zi)
|
||||
component["DTEND"] = vDDDTypes(end_dt)
|
||||
|
||||
# Update timestamps
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
component["LAST-MODIFIED"] = vDDDTypes(now)
|
||||
component["DTSTAMP"] = vDDDTypes(now)
|
||||
|
||||
# Ensure VTIMEZONE definitions exist for any TZID we just attached.
|
||||
existing_tzids = {
|
||||
str(sub.get("TZID", ""))
|
||||
for sub in cal.subcomponents
|
||||
if sub.name == "VTIMEZONE"
|
||||
}
|
||||
for zi in used_timezones:
|
||||
if str(zi) not in existing_tzids:
|
||||
cal.add_component(Timezone.from_tzinfo(zi))
|
||||
|
||||
break
|
||||
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
@@ -25,8 +25,30 @@ class CalendarEventSummary(BaseModel):
|
||||
|
||||
uid: str = Field(description="Event UID")
|
||||
summary: str = Field(description="Event summary/title")
|
||||
start: str = Field(description="Event start datetime (ISO format)")
|
||||
end: Optional[str] = Field(None, description="Event end datetime (ISO format)")
|
||||
start: str = Field(
|
||||
description=(
|
||||
"Event start datetime (ISO format). No suffix = RFC 5545 floating "
|
||||
"local time; ``+00:00`` = UTC; an explicit offset (e.g. ``-04:00``) "
|
||||
"= TZID-bound at that instant. The IANA TZID name is exposed "
|
||||
"separately as ``start_tz``."
|
||||
)
|
||||
)
|
||||
end: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Event end datetime (ISO format). Same encoding rules as ``start``."
|
||||
),
|
||||
)
|
||||
start_tz: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"IANA timezone name when DTSTART had a TZID parameter (e.g. "
|
||||
"``America/New_York``). ``None`` for floating local or UTC."
|
||||
),
|
||||
)
|
||||
end_tz: Optional[str] = Field(
|
||||
None, description="IANA timezone name when DTEND had a TZID parameter."
|
||||
)
|
||||
all_day: bool = Field(default=False, description="Whether event is all-day")
|
||||
location: Optional[str] = Field(None, description="Event location")
|
||||
description: Optional[str] = Field(None, description="Event description")
|
||||
|
||||
@@ -38,6 +38,8 @@ def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
|
||||
summary=event.get("title", ""),
|
||||
start=start,
|
||||
end=event.get("end_datetime"),
|
||||
start_tz=event.get("start_tz"),
|
||||
end_tz=event.get("end_tz"),
|
||||
all_day=event.get("all_day", False),
|
||||
location=event.get("location") or None,
|
||||
description=event.get("description") or None,
|
||||
@@ -92,13 +94,23 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
attendees: str = "",
|
||||
url: str = "",
|
||||
color: str = "",
|
||||
timezone: str = "",
|
||||
):
|
||||
"""Create a comprehensive calendar event with full feature support
|
||||
"""Create a comprehensive calendar event with full feature support.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to create the event in
|
||||
title: Event title
|
||||
start_datetime: ISO format: "2025-01-15T14:00:00" or "2025-01-15" for all-day
|
||||
start_datetime: ISO format. Three modes:
|
||||
- ``"2025-01-15T14:00:00Z"`` or ``"2025-01-15T14:00:00+00:00"``
|
||||
→ stored as UTC.
|
||||
- ``"2025-01-15T14:00:00"`` with ``timezone="America/New_York"``
|
||||
→ stored as TZID-bound (server emits ``DTSTART;TZID=...:...``
|
||||
plus a VTIMEZONE component).
|
||||
- ``"2025-01-15T14:00:00"`` alone → stored as RFC 5545 floating
|
||||
local time (interpreted by viewers in their own zone). A
|
||||
warning is logged so the choice is visible.
|
||||
- ``"2025-01-15"`` for all-day events.
|
||||
ctx: MCP context
|
||||
end_datetime: ISO format end time, empty for all-day events
|
||||
all_day: Whether this is an all-day event
|
||||
@@ -116,6 +128,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
attendees: Comma-separated email addresses
|
||||
url: Related URL for the event
|
||||
color: Event color (hex or name)
|
||||
timezone: Optional IANA timezone name (e.g. ``"America/New_York"``).
|
||||
Applied only when ``start_datetime``/``end_datetime`` are naive
|
||||
(no offset, no ``Z``). Ignored — with a warning — when the
|
||||
inputs already carry an explicit offset.
|
||||
|
||||
Returns:
|
||||
Dict with event creation result
|
||||
@@ -141,6 +157,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
"attendees": attendees,
|
||||
"url": url,
|
||||
"color": color,
|
||||
"timezone": timezone,
|
||||
}
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
@@ -312,9 +329,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
attendees: str | None = None,
|
||||
url: str | None = None,
|
||||
color: str | None = None,
|
||||
timezone: str | None = None,
|
||||
etag: str = "",
|
||||
):
|
||||
"""Update any aspect of an existing event"""
|
||||
"""Update any aspect of an existing event.
|
||||
|
||||
Pass ``timezone`` (IANA name, e.g. ``"America/New_York"``) together
|
||||
with a naive ``start_datetime`` / ``end_datetime`` to rewrite DTSTART
|
||||
/ DTEND as TZID-bound. See ``nc_calendar_create_event`` for the full
|
||||
encoding rules.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build update data with only non-None values
|
||||
@@ -353,6 +377,8 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
event_data["url"] = url
|
||||
if color is not None:
|
||||
event_data["color"] = color
|
||||
if timezone is not None:
|
||||
event_data["timezone"] = timezone
|
||||
|
||||
return await client.calendar.update_event(
|
||||
calendar_name, event_uid, event_data, etag
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav>=3.0.1,<4.0",
|
||||
"recurring-ical-events>=3.8.0,<4.0", # Client-side recurrence expansion (preserves TZID/floating semantics)
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||
"alembic>=1.14.0", # Database migrations
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Unit tests for calendar timezone roundtrip (issue #782).
|
||||
|
||||
These tests cover the three storage flavors that ``_extract_vevent_data`` and
|
||||
``_create_ical_event`` must handle correctly:
|
||||
|
||||
- **Floating local time** (no ``Z``, no offset, no TZID) — RFC 5545's neutral
|
||||
wall-clock format.
|
||||
- **UTC** (``Z`` or ``+00:00`` suffix).
|
||||
- **TZID-bound** (``DTSTART;TZID=...:...`` with a paired ``VTIMEZONE``).
|
||||
|
||||
Prior to issue #782 the read path silently coerced everything to UTC because
|
||||
``_search_events_by_date`` requested server-side ``<C:expand>``; these tests
|
||||
pin the post-fix contract so the regression cannot return.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client.calendar import CalendarClient
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _make_client(mocker) -> CalendarClient:
|
||||
"""Build a CalendarClient without performing any network IO.
|
||||
|
||||
The pure iCal helpers under test (``_create_ical_event`` /
|
||||
``_parse_ical_event``) don't touch the wire, so a stub AsyncClient is fine.
|
||||
"""
|
||||
client = CalendarClient.__new__(CalendarClient)
|
||||
client._client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
client._username = "tester"
|
||||
return client
|
||||
|
||||
|
||||
# ============= Read path: _parse_ical_event preserves DTSTART semantics =============
|
||||
|
||||
|
||||
def _wrap_vevent(vevent_body: str, vtimezone: str = "") -> str:
|
||||
"""Assemble a minimal VCALENDAR around a VEVENT body for parser tests."""
|
||||
return (
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"PRODID:-//Test//EN\r\n"
|
||||
f"{vtimezone}"
|
||||
"BEGIN:VEVENT\r\n"
|
||||
"UID:test-event\r\n"
|
||||
"SUMMARY:Test\r\n"
|
||||
f"{vevent_body}"
|
||||
"DTSTAMP:20260510T000000Z\r\n"
|
||||
"END:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_floating_event_has_no_offset_and_no_tzid(mocker):
|
||||
"""Floating-local DTSTART must round-trip as a naive ISO string with no spurious offset."""
|
||||
client = _make_client(mocker)
|
||||
ical = _wrap_vevent("DTSTART:20260513T143000\r\nDTEND:20260513T154500\r\n")
|
||||
|
||||
parsed = client._parse_ical_event(ical)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["start_datetime"] == "2026-05-13T14:30:00"
|
||||
assert parsed["end_datetime"] == "2026-05-13T15:45:00"
|
||||
assert "start_tz" not in parsed
|
||||
assert "end_tz" not in parsed
|
||||
assert parsed["all_day"] is False
|
||||
|
||||
|
||||
def test_parse_utc_event_keeps_explicit_zero_offset(mocker):
|
||||
"""``DTSTART:...Z`` must serialize back as ``+00:00`` so callers can recognize UTC."""
|
||||
client = _make_client(mocker)
|
||||
ical = _wrap_vevent("DTSTART:20260512T143000Z\r\nDTEND:20260512T154500Z\r\n")
|
||||
|
||||
parsed = client._parse_ical_event(ical)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["start_datetime"] == "2026-05-12T14:30:00+00:00"
|
||||
assert parsed["end_datetime"] == "2026-05-12T15:45:00+00:00"
|
||||
assert "start_tz" not in parsed
|
||||
|
||||
|
||||
def test_parse_tzid_event_exposes_iana_name_and_offset(mocker):
|
||||
"""TZID-bound events must expose both the resolved offset and the IANA name."""
|
||||
client = _make_client(mocker)
|
||||
vtz = (
|
||||
"BEGIN:VTIMEZONE\r\n"
|
||||
"TZID:America/New_York\r\n"
|
||||
"BEGIN:STANDARD\r\n"
|
||||
"DTSTART:20071104T020000\r\n"
|
||||
"TZOFFSETFROM:-0400\r\n"
|
||||
"TZOFFSETTO:-0500\r\n"
|
||||
"RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU\r\n"
|
||||
"END:STANDARD\r\n"
|
||||
"BEGIN:DAYLIGHT\r\n"
|
||||
"DTSTART:20070311T020000\r\n"
|
||||
"TZOFFSETFROM:-0500\r\n"
|
||||
"TZOFFSETTO:-0400\r\n"
|
||||
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU\r\n"
|
||||
"END:DAYLIGHT\r\n"
|
||||
"END:VTIMEZONE\r\n"
|
||||
)
|
||||
ical = _wrap_vevent(
|
||||
"DTSTART;TZID=America/New_York:20260514T100000\r\n"
|
||||
"DTEND;TZID=America/New_York:20260514T110000\r\n",
|
||||
vtimezone=vtz,
|
||||
)
|
||||
|
||||
parsed = client._parse_ical_event(ical)
|
||||
|
||||
assert parsed is not None
|
||||
# May is EDT (UTC-4)
|
||||
assert parsed["start_datetime"] == "2026-05-14T10:00:00-04:00"
|
||||
assert parsed["end_datetime"] == "2026-05-14T11:00:00-04:00"
|
||||
assert parsed["start_tz"] == "America/New_York"
|
||||
assert parsed["end_tz"] == "America/New_York"
|
||||
|
||||
|
||||
def test_parse_all_day_event(mocker):
|
||||
"""All-day events serialize as plain dates with all_day=True."""
|
||||
client = _make_client(mocker)
|
||||
ical = _wrap_vevent("DTSTART;VALUE=DATE:20260601\r\nDTEND;VALUE=DATE:20260602\r\n")
|
||||
|
||||
parsed = client._parse_ical_event(ical)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["all_day"] is True
|
||||
assert parsed["start_datetime"] == "2026-06-01"
|
||||
|
||||
|
||||
# ============= Write path: timezone parameter wires TZID + VTIMEZONE =============
|
||||
|
||||
|
||||
def test_create_ical_event_utc_input_stores_as_z_suffix(mocker):
|
||||
"""Offset-aware input continues to emit RFC 5545 UTC (``...Z``) on the wire."""
|
||||
client = _make_client(mocker)
|
||||
event_data = {
|
||||
"title": "UTC event",
|
||||
"start_datetime": "2026-05-12T14:30:00+00:00",
|
||||
"end_datetime": "2026-05-12T15:45:00+00:00",
|
||||
}
|
||||
|
||||
ical = client._create_ical_event(event_data, event_uid="utc-uid")
|
||||
|
||||
assert "DTSTART:20260512T143000Z" in ical
|
||||
assert "DTEND:20260512T154500Z" in ical
|
||||
assert "VTIMEZONE" not in ical
|
||||
|
||||
|
||||
def test_create_ical_event_naive_without_tz_stores_floating(mocker):
|
||||
"""Naive input + no ``timezone`` parameter must store as floating local time."""
|
||||
client = _make_client(mocker)
|
||||
event_data = {
|
||||
"title": "Floating event",
|
||||
"start_datetime": "2026-05-13T14:30:00",
|
||||
"end_datetime": "2026-05-13T15:45:00",
|
||||
}
|
||||
|
||||
ical = client._create_ical_event(event_data, event_uid="floating-uid")
|
||||
|
||||
# No TZID, no Z suffix — RFC 5545 floating local time.
|
||||
assert "DTSTART:20260513T143000" in ical
|
||||
assert "DTSTART;TZID" not in ical
|
||||
assert "20260513T143000Z" not in ical
|
||||
assert "VTIMEZONE" not in ical
|
||||
|
||||
|
||||
def test_create_ical_event_naive_with_timezone_emits_tzid_and_vtimezone(mocker):
|
||||
"""Naive input + ``timezone="America/New_York"`` produces TZID-bound DTSTART + VTIMEZONE."""
|
||||
client = _make_client(mocker)
|
||||
event_data = {
|
||||
"title": "TZID event",
|
||||
"start_datetime": "2026-05-14T10:00:00",
|
||||
"end_datetime": "2026-05-14T11:00:00",
|
||||
"timezone": "America/New_York",
|
||||
}
|
||||
|
||||
ical = client._create_ical_event(event_data, event_uid="tzid-uid")
|
||||
|
||||
assert "DTSTART;TZID=America/New_York:20260514T100000" in ical
|
||||
assert "DTEND;TZID=America/New_York:20260514T110000" in ical
|
||||
# VTIMEZONE component must be emitted so other CalDAV clients can interpret the TZID.
|
||||
assert "BEGIN:VTIMEZONE" in ical
|
||||
assert "TZID:America/New_York" in ical
|
||||
|
||||
|
||||
def test_create_ical_event_offset_input_ignores_timezone_param(mocker):
|
||||
"""When the input already carries an offset, ``timezone`` is ignored (warning logged)."""
|
||||
client = _make_client(mocker)
|
||||
event_data = {
|
||||
"title": "Mixed-signals event",
|
||||
"start_datetime": "2026-05-12T14:30:00+00:00",
|
||||
"end_datetime": "2026-05-12T15:45:00+00:00",
|
||||
"timezone": "America/New_York",
|
||||
}
|
||||
|
||||
ical = client._create_ical_event(event_data, event_uid="mixed-uid")
|
||||
|
||||
assert "DTSTART:20260512T143000Z" in ical
|
||||
assert "DTSTART;TZID" not in ical
|
||||
# No VTIMEZONE since we never attached a ZoneInfo.
|
||||
assert "VTIMEZONE" not in ical
|
||||
|
||||
|
||||
def test_create_ical_event_unknown_timezone_falls_back_to_floating(mocker, caplog):
|
||||
"""An unresolvable IANA name must not crash — fall back to floating local time."""
|
||||
client = _make_client(mocker)
|
||||
event_data = {
|
||||
"title": "Bogus TZ event",
|
||||
"start_datetime": "2026-05-15T09:00:00",
|
||||
"end_datetime": "2026-05-15T10:00:00",
|
||||
"timezone": "Continent/Imaginary",
|
||||
}
|
||||
|
||||
ical = client._create_ical_event(event_data, event_uid="bogus-uid")
|
||||
|
||||
assert "DTSTART:20260515T090000" in ical
|
||||
assert "VTIMEZONE" not in ical
|
||||
|
||||
|
||||
# ============= End-to-end: write → re-parse roundtrip preserves intent =============
|
||||
|
||||
|
||||
def test_roundtrip_tzid_event_preserves_iana_name(mocker):
|
||||
"""The TZID name placed on write must survive a re-parse on read."""
|
||||
client = _make_client(mocker)
|
||||
event_data = {
|
||||
"title": "Roundtrip TZID",
|
||||
"start_datetime": "2026-05-14T10:00:00",
|
||||
"end_datetime": "2026-05-14T11:00:00",
|
||||
"timezone": "America/New_York",
|
||||
}
|
||||
|
||||
ical = client._create_ical_event(event_data, event_uid="roundtrip-uid")
|
||||
parsed = client._parse_ical_event(ical)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["start_tz"] == "America/New_York"
|
||||
assert parsed["start_datetime"] == "2026-05-14T10:00:00-04:00"
|
||||
@@ -2158,6 +2158,7 @@ dependencies = [
|
||||
{ name = "python-json-logger" },
|
||||
{ name = "pythonvcard4" },
|
||||
{ name = "qdrant-client" },
|
||||
{ name = "recurring-ical-events" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
|
||||
@@ -2212,6 +2213,7 @@ requires-dist = [
|
||||
{ name = "python-json-logger", specifier = ">=3.2.0" },
|
||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||
{ name = "qdrant-client", specifier = ">=1.17.0" },
|
||||
{ name = "recurring-ical-events", specifier = ">=3.8.0,<4.0" },
|
||||
{ name = "starlette", specifier = "<1.0" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user