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:
Chris Coutinho
2026-05-11 02:41:10 +02:00
co-authored by Claude Opus 4.7
parent 728a9ae252
commit 9d5ac01f24
6 changed files with 469 additions and 75 deletions
+171 -70
View File
@@ -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")
+24 -2
View File
@@ -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")
+29 -3
View File
@@ -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
+1
View File
@@ -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"
Generated
+2
View File
@@ -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" },
]