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()