diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 137fde9..1352e40 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -470,8 +470,21 @@ def _expand_rrule( return [d] # Parse the rrule, applying it to the base dtstart. + rrule_str = ev.rrule + if ev.dtstart is not None and getattr(ev.dtstart, "tzinfo", None) is None: + # Events are stored with a naive (UTC) dtstart, but standard .ics + # exporters (Google/Apple/Outlook/Fastmail) write the bound as an + # absolute UTC value, e.g. UNTIL=20240105T090000Z. dateutil refuses to + # mix a tz-aware UNTIL with a naive DTSTART ("RRULE UNTIL values must be + # specified in UTC when DTSTART is timezone-aware"), so the except branch + # below would silently collapse the whole series to a single event. + # Drop the trailing Z so UNTIL matches the naive DTSTART. + import re as _re + rrule_str = _re.sub( + r"(UNTIL=\d{8}(?:T\d{6})?)Z", r"\1", rrule_str, flags=_re.IGNORECASE + ) try: - rule = rrulestr(ev.rrule, dtstart=ev.dtstart) + rule = rrulestr(rrule_str, dtstart=ev.dtstart) except Exception as ex: logger.warning( "Failed to parse rrule=%r for event %s: %s", ev.rrule, ev.uid, ex diff --git a/tests/test_calendar_rrule_until_utc.py b/tests/test_calendar_rrule_until_utc.py new file mode 100644 index 0000000..9aade26 --- /dev/null +++ b/tests/test_calendar_rrule_until_utc.py @@ -0,0 +1,73 @@ +"""Regression test for RRULE expansion with a UTC UNTIL value. + +Standard ICS exporters (Google Calendar, Apple Calendar, Outlook, +Fastmail) emit recurrence rules of the form + + RRULE:FREQ=DAILY;UNTIL=20240105T090000Z + +When such an event is imported, the calendar route stores the event's +``dtstart`` as a *naive* datetime (the DB column is naive; timed events +are converted to naive-UTC on import). dateutil >= 2.7 raises + + ValueError: RRULE UNTIL values must be specified in UTC + when DTSTART is timezone-aware + +whenever the UNTIL is tz-aware (carries a trailing ``Z``) but the +``dtstart`` is naive. ``_expand_rrule`` catches that ValueError and +*silently downgrades the event to non-recurring*, so every occurrence +after the first vanishes from the calendar. + +This test pins the correct behaviour: a daily series bounded by a UTC +UNTIL must expand to all of its occurrences. +""" + +from datetime import datetime +from types import SimpleNamespace + +from tests.test_null_owner_gates import _import_calendar_helpers + + +_MOCK_CAL = SimpleNamespace(name="Personal", color="#5b8abf") + + +def _make_event(**overrides): + defaults = { + "uid": "evt-until-utc", + "summary": "Standup", + "dtstart": datetime(2024, 1, 1, 9, 0), + "dtend": datetime(2024, 1, 1, 9, 30), + "all_day": False, + "is_utc": True, + "rrule": "", + "calendar_id": "cal-001", + "color": None, + "description": "", + "location": "", + "event_type": None, + "importance": "normal", + } + defaults.update(overrides) + ev = SimpleNamespace(**defaults) + ev.calendar = _MOCK_CAL + return ev + + +def test_expand_rrule_with_utc_until_keeps_all_occurrences(): + """FREQ=DAILY;UNTIL=...Z must expand to every occurrence, not collapse + to a single non-recurring event.""" + cal = _import_calendar_helpers() + ev = _make_event(rrule="FREQ=DAILY;UNTIL=20240105T090000Z") + + results = cal._expand_rrule(ev, datetime(2024, 1, 1), datetime(2024, 1, 10)) + + # Jan 1, 2, 3, 4, 5 — five daily occurrences up to and including UNTIL. + assert len(results) == 5, ( + f"Expected 5 daily occurrences bounded by UTC UNTIL, got " + f"{len(results)}: {[r['uid'] for r in results]}" + ) + assert all(r["is_recurrence"] is True for r in results), ( + "Occurrences must be flagged as recurrences, not silently downgraded " + f"to non-recurring: {[(r['uid'], r['is_recurrence']) for r in results]}" + ) + assert results[0]["uid"] == "evt-until-utc::2024-01-01T09:00" + assert results[-1]["uid"] == "evt-until-utc::2024-01-05T09:00"