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.
107 lines
3.4 KiB
Python
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]
|