238 lines
8.5 KiB
Python
238 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
|
|
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent
|
|
|
|
|
|
class ICalendarParsingTests(unittest.TestCase):
|
|
def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None:
|
|
payload = """BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
BEGIN:VEVENT
|
|
UID:event-1@example.test
|
|
DTSTAMP:20260707T080000Z
|
|
DTSTART;TZID=Europe/Berlin:20260708T090000
|
|
DTEND;TZID=Europe/Berlin:20260708T100000
|
|
SUMMARY:Planning
|
|
ATTENDEE;CN=Ada;ROLE=REQ-PARTICIPANT:mailto:ada@example.test
|
|
RRULE:FREQ=WEEKLY;COUNT=3
|
|
X-GOVOPLAN-CUSTOM:kept
|
|
END:VEVENT
|
|
END:VCALENDAR
|
|
"""
|
|
event = parse_vevent(payload)
|
|
|
|
self.assertEqual(event["uid"], "event-1@example.test")
|
|
self.assertEqual(event["summary"], "Planning")
|
|
self.assertEqual(event["timezone"], "Europe/Berlin")
|
|
self.assertEqual(event["rrule"], {"FREQ": "WEEKLY", "COUNT": "3"})
|
|
self.assertEqual(event["attendees"][0]["params"]["CN"], ["Ada"])
|
|
property_names = [item["name"] for item in event["icalendar"]["properties"]]
|
|
self.assertIn("X-GOVOPLAN-CUSTOM", property_names)
|
|
|
|
def test_parse_all_day_event(self) -> None:
|
|
event = parse_vevent(
|
|
"""BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
BEGIN:VEVENT
|
|
UID:event-2@example.test
|
|
DTSTART;VALUE=DATE:20260708
|
|
DTEND;VALUE=DATE:20260709
|
|
SUMMARY:All day
|
|
END:VEVENT
|
|
END:VCALENDAR
|
|
"""
|
|
)
|
|
|
|
self.assertTrue(event["all_day"])
|
|
self.assertEqual(event["start_at"].hour, 0)
|
|
|
|
def test_parse_advanced_vevent_fields_and_valarm(self) -> None:
|
|
event = parse_vevent(
|
|
"""BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
METHOD:REQUEST
|
|
BEGIN:VEVENT
|
|
UID:event-advanced@example.test
|
|
DTSTAMP:20260707T080000Z
|
|
CREATED:20260707T070000Z
|
|
LAST-MODIFIED:20260707T073000Z
|
|
DTSTART;TZID=Europe/Berlin:20260708T090000
|
|
DURATION:PT1H30M
|
|
SUMMARY:Planning
|
|
DESCRIPTION:Line one\\nLine two
|
|
LOCATION:Room\\; 1
|
|
PRIORITY:5
|
|
URL:https://example.test/events/event-advanced
|
|
GEO:52.5200;13.4050
|
|
COMMENT:First comment
|
|
RESOURCES:Projector,Room
|
|
REQUEST-STATUS:2.0;Success
|
|
ORGANIZER;CN=Orga:mailto:orga@example.test
|
|
ATTENDEE;CN=Ada;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:ada@example.test
|
|
RRULE:FREQ=WEEKLY;COUNT=2
|
|
EXDATE;TZID=Europe/Berlin:20260715T090000
|
|
RDATE;TZID=Europe/Berlin:20260722T090000
|
|
ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf
|
|
RELATED-TO;RELTYPE=PARENT:parent@example.test
|
|
X-GOVOPLAN-CUSTOM:kept
|
|
BEGIN:VALARM
|
|
ACTION:DISPLAY
|
|
DESCRIPTION:Reminder
|
|
TRIGGER:-PT15M
|
|
END:VALARM
|
|
END:VEVENT
|
|
END:VCALENDAR
|
|
"""
|
|
)
|
|
|
|
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
|
|
self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
|
|
self.assertEqual(event["duration_seconds"], 5400)
|
|
self.assertEqual(event["description"], "Line one\nLine two")
|
|
self.assertEqual(event["location"], "Room; 1")
|
|
self.assertEqual(event["timezone"], "Europe/Berlin")
|
|
self.assertEqual(event["organizer"]["params"]["CN"], ["Orga"])
|
|
self.assertEqual(event["attendees"][0]["params"]["PARTSTAT"], ["ACCEPTED"])
|
|
self.assertEqual(event["icalendar"]["method"], "REQUEST")
|
|
self.assertEqual(event["icalendar"]["standard"]["priority"]["value"], "5")
|
|
self.assertEqual(event["icalendar"]["standard"]["url"]["value"], "https://example.test/events/event-advanced")
|
|
self.assertEqual(event["reminders"][0]["trigger"]["value"], "-PT15M")
|
|
self.assertEqual(event["reminders"][0]["trigger"]["decoded"], -900)
|
|
property_names = [item["name"] for item in event["icalendar"]["properties"]]
|
|
self.assertIn("REQUEST-STATUS", property_names)
|
|
self.assertIn("X-GOVOPLAN-CUSTOM", property_names)
|
|
|
|
def test_generated_ics_preserves_unmapped_properties_and_alarms(self) -> None:
|
|
event = SimpleNamespace(
|
|
uid="event-export@example.test",
|
|
start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc),
|
|
duration_seconds=None,
|
|
all_day=False,
|
|
timezone="Europe/Berlin",
|
|
summary="Generated",
|
|
sequence=4,
|
|
status="CONFIRMED",
|
|
transparency="OPAQUE",
|
|
classification="PUBLIC",
|
|
description=None,
|
|
location=None,
|
|
categories=["GovOPlaN"],
|
|
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
|
|
organizer={"value": "mailto:orga@example.test", "params": {"CN": ["Orga"]}},
|
|
attendees=[{"value": "mailto:ada@example.test", "params": {"CN": ["Ada"], "ROLE": ["REQ-PARTICIPANT"]}}],
|
|
rdate=[],
|
|
exdate=[],
|
|
reminders=[],
|
|
attachments=[{"name": "ATTACH", "value": "https://example.test/a.pdf", "params": {"FMTTYPE": ["application/pdf"]}}],
|
|
related_to=[{"name": "RELATED-TO", "value": "parent@example.test", "params": {"RELTYPE": ["PARENT"]}}],
|
|
icalendar={
|
|
"method": "PUBLISH",
|
|
"properties": [
|
|
{"name": "URL", "value": "https://example.test/events/event-export", "params": {}},
|
|
{"name": "X-GOVOPLAN-CUSTOM", "value": "kept", "params": {}},
|
|
{"name": "SUMMARY", "value": "Stale summary", "params": {}},
|
|
],
|
|
"alarms": [
|
|
{
|
|
"component": "VALARM",
|
|
"properties": [
|
|
{"name": "ACTION", "value": "DISPLAY", "params": {}},
|
|
{"name": "DESCRIPTION", "value": "Reminder", "params": {}},
|
|
{"name": "TRIGGER", "value": "-PT15M", "params": {}},
|
|
],
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
ics = event_to_ics(event)
|
|
|
|
self.assertIn("METHOD:PUBLISH", ics)
|
|
self.assertIn("DTSTART;TZID=Europe/Berlin:20260708T090000", ics)
|
|
self.assertIn("SUMMARY:Generated", ics)
|
|
self.assertNotIn("SUMMARY:Stale summary", ics)
|
|
self.assertIn("RRULE:FREQ=WEEKLY;COUNT=2", ics)
|
|
self.assertIn("ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf", ics)
|
|
self.assertIn("RELATED-TO;RELTYPE=PARENT:parent@example.test", ics)
|
|
self.assertIn("URL:https://example.test/events/event-export", ics)
|
|
self.assertIn("X-GOVOPLAN-CUSTOM:kept", ics)
|
|
self.assertIn("BEGIN:VALARM", ics)
|
|
self.assertIn("TRIGGER:-PT15M", ics)
|
|
|
|
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
|
|
parsed = parse_vevent(
|
|
"""BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
BEGIN:VEVENT
|
|
UID:event-recur@example.test
|
|
DTSTART;TZID=Europe/Berlin:20260708T090000
|
|
DTEND;TZID=Europe/Berlin:20260708T100000
|
|
SUMMARY:Recurring
|
|
RRULE:FREQ=WEEKLY;COUNT=3
|
|
EXDATE;TZID=Europe/Berlin:20260715T090000
|
|
RDATE;TZID=Europe/Berlin:20260729T090000
|
|
END:VEVENT
|
|
END:VCALENDAR
|
|
"""
|
|
)
|
|
event = SimpleNamespace(**parsed)
|
|
|
|
occurrences = expand_event_occurrences(
|
|
event,
|
|
datetime(2026, 7, 1, tzinfo=timezone.utc),
|
|
datetime(2026, 7, 31, 23, 59, tzinfo=timezone.utc),
|
|
)
|
|
|
|
self.assertEqual(
|
|
[item["start_at"] for item in occurrences],
|
|
[
|
|
datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
|
|
datetime(2026, 7, 22, 7, 0, tzinfo=timezone.utc),
|
|
datetime(2026, 7, 29, 7, 0, tzinfo=timezone.utc),
|
|
],
|
|
)
|
|
self.assertEqual(occurrences[0]["end_at"], datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc))
|
|
|
|
def test_generated_ics_contains_required_vevent_fields(self) -> None:
|
|
class Event:
|
|
uid = "event-3@example.test"
|
|
start_at = parse_vevent(
|
|
"""BEGIN:VCALENDAR
|
|
BEGIN:VEVENT
|
|
UID:x
|
|
DTSTART:20260708T090000Z
|
|
SUMMARY:x
|
|
END:VEVENT
|
|
END:VCALENDAR
|
|
"""
|
|
)["start_at"]
|
|
end_at = None
|
|
all_day = False
|
|
timezone = None
|
|
summary = "Generated"
|
|
sequence = 0
|
|
status = "CONFIRMED"
|
|
transparency = "OPAQUE"
|
|
classification = "PUBLIC"
|
|
description = None
|
|
location = None
|
|
categories = []
|
|
rrule = None
|
|
organizer = None
|
|
attendees = []
|
|
|
|
ics = event_to_ics(Event())
|
|
|
|
self.assertIn("BEGIN:VEVENT", ics)
|
|
self.assertIn("UID:event-3@example.test", ics)
|
|
self.assertIn("SUMMARY:Generated", ics)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|