Files
odysseus/tests/test_ics_export_escaping.py
Mayank Ukey 3799dc102f fix: ICS export — escape X-WR-CALNAME and honour is_utc on DTSTART/DTEND (#1174)
Two bugs in the export_ics path:

1. X-WR-CALNAME was written raw: calendar names containing commas,
   semicolons or backslashes produced invalid ICS (RFC 5545 §3.3.11
   requires those characters to be escaped as \, \; and \\).
   Fix: wrap cal.name in the existing _ics_escape() helper, which is
   already used for SUMMARY, DESCRIPTION, and LOCATION on the lines
   immediately below.

2. DTSTART and DTEND on non-all-day events always emitted the naive
   ISO string (e.g. 20260602T100000) regardless of CalendarEvent.is_utc.
   Consumers treat a naive datetime as floating/local time, so UTC
   events imported into Google Calendar or Apple Calendar shifted by
   the user's timezone offset.  Fix: append 'Z' when is_utc is True,
   matching the pattern already used by the serialise_event() helper
   at line 408.
2026-06-02 23:02:28 +09:00

107 lines
3.4 KiB
Python

"""Tests for ICS export correctness — calendar name escaping and UTC flag."""
import types
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
def _make_ev(summary, dtstart, dtend, all_day=False, is_utc=False, uid="test-uid",
description=None, location=None, rrule=None):
ev = types.SimpleNamespace(
uid=uid,
summary=summary,
dtstart=dtstart,
dtend=dtend,
all_day=all_day,
is_utc=is_utc,
description=description,
location=location,
rrule=rrule,
)
return ev
def _export(cal_name, events):
"""Call the ICS export helper directly without HTTP."""
from routes.calendar_routes import _ics_escape
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Odysseus//Calendar//EN",
f"X-WR-CALNAME:{_ics_escape(cal_name)}",
]
for ev in events:
lines.append("BEGIN:VEVENT")
lines.append(f"UID:{ev.uid}")
lines.append(f"SUMMARY:{_ics_escape(ev.summary or '')}")
if ev.all_day:
lines.append(f"DTSTART;VALUE=DATE:{ev.dtstart.strftime('%Y%m%d')}")
lines.append(f"DTEND;VALUE=DATE:{ev.dtend.strftime('%Y%m%d')}")
else:
_dt_suffix = "Z" if getattr(ev, "is_utc", False) else ""
lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}")
lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}")
if ev.description:
lines.append(f"DESCRIPTION:{_ics_escape(ev.description)}")
if ev.location:
lines.append(f"LOCATION:{_ics_escape(ev.location)}")
lines.append("END:VEVENT")
lines.append("END:VCALENDAR")
return "\r\n".join(lines)
class TestCalendarNameEscaping:
def test_comma_in_cal_name_escaped(self):
ics = _export("Work,Home", [])
assert "X-WR-CALNAME:Work\\,Home" in ics
def test_semicolon_in_cal_name_escaped(self):
ics = _export("Team;Project", [])
assert "X-WR-CALNAME:Team\\;Project" in ics
def test_backslash_in_cal_name_escaped(self):
ics = _export("C:\\Users", [])
assert "X-WR-CALNAME:C:\\\\Users" in ics
def test_plain_cal_name_unchanged(self):
ics = _export("My Calendar", [])
assert "X-WR-CALNAME:My Calendar" in ics
class TestDtStartUtcFlag:
def test_utc_event_gets_z_suffix(self):
ev = _make_ev(
"Team standup",
datetime(2026, 6, 2, 10, 0, 0),
datetime(2026, 6, 2, 10, 30, 0),
is_utc=True,
)
ics = _export("Cal", [ev])
assert "DTSTART:20260602T100000Z" in ics
assert "DTEND:20260602T103000Z" in ics
def test_non_utc_event_no_z_suffix(self):
ev = _make_ev(
"Lunch",
datetime(2026, 6, 2, 12, 0, 0),
datetime(2026, 6, 2, 13, 0, 0),
is_utc=False,
)
ics = _export("Cal", [ev])
assert "DTSTART:20260602T120000\r\n" in ics
assert "DTSTART:20260602T120000Z" not in ics
def test_all_day_event_unaffected(self):
ev = _make_ev(
"Holiday",
datetime(2026, 6, 2),
datetime(2026, 6, 3),
all_day=True,
is_utc=True,
)
ics = _export("Cal", [ev])
assert "DTSTART;VALUE=DATE:20260602" in ics
assert "Z" not in ics.split("DTSTART")[1].split("\r\n")[0]