From 7301cac8ca9a60d2deba4c20a68adaadcfaf676b Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 11 Mar 2026 08:35:21 +0100 Subject: [PATCH] fix(#203): add iCal workaround where if ORGANIZER has no parameters --- .../Plugin/Parsers/IcalResponseParser.php | 7 +- tests/Feature/Plugins/Ical/IcalParserTest.php | 163 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Plugins/Ical/IcalParserTest.php diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php index c8f2b58..a1caedb 100644 --- a/app/Services/Plugin/Parsers/IcalResponseParser.php +++ b/app/Services/Plugin/Parsers/IcalResponseParser.php @@ -25,7 +25,12 @@ class IcalResponseParser implements ResponseParser } try { - $this->parser->parseString($body); + // Workaround for om/icalparser v4.0.0 bug where it fails if ORGANIZER or ATTENDEE has no parameters. + // When ORGANIZER or ATTENDEE has no parameters (no semicolon after the key), + // IcalParser::parseRow returns an empty string for $middle instead of an array, + // which causes a type error in a foreach loop in IcalParser::parseString. + $normalizedBody = preg_replace('/^(ORGANIZER|ATTENDEE):/m', '$1;CN=Unknown:', $body); + $this->parser->parseString($normalizedBody); $events = $this->parser->getEvents()->sorted()->getArrayCopy(); $windowStart = now()->subDays(7); diff --git a/tests/Feature/Plugins/Ical/IcalParserTest.php b/tests/Feature/Plugins/Ical/IcalParserTest.php new file mode 100644 index 0000000..a394274 --- /dev/null +++ b/tests/Feature/Plugins/Ical/IcalParserTest.php @@ -0,0 +1,163 @@ + Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/calendar.ics', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + $plugin->refresh(); + + expect($plugin->data_payload)->not->toHaveKey('error'); + expect($plugin->data_payload)->toHaveKey('ical'); + expect($plugin->data_payload['ical'])->toHaveCount(1); + expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Meeting'); + + Carbon::setTestNow(); +}); + +test('iCal plugin parses recurring events with multiple BYDAY correctly', function (): void { + // Set test now to Monday 2024-03-25 + Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC')); + + $icalContent = <<<'ICS' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//EN +BEGIN:VEVENT +DESCRIPTION:XXX-REDACTED +RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO +UID:040000008200E00074C5B7101A82E00800000000E07AF34F937EDA01000000000000000 + 01000000061F3E918C753424E8154B36E55452933 +SUMMARY:Recurring Meeting +DTSTART;VALUE=DATE:20240326 +DTEND;VALUE=DATE:20240327 +DTSTAMP:20240605T082436Z +CLASS:PUBLIC +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR +ICS; + + Http::fake([ + 'example.com/recurring.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/recurring.ics', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + $plugin->refresh(); + + $ical = $plugin->data_payload['ical']; + + // Week of March 25, 2024: + // Tue March 26: 2024-03-26 (DTSTART) + // Thu March 28: 2024-03-28 (Recurrence) + + // The parser window is now-7 days to now+30 days. + // Window: 2024-03-18 to 2024-04-24. + + $summaries = collect($ical)->pluck('SUMMARY'); + expect($summaries)->toContain('Recurring Meeting'); + + $dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values(); + + // Check if Tuesday March 26 is present + expect($dates)->toContain('2024-03-26'); + + // Check if Thursday March 28 is present (THIS IS WHERE IT IS EXPECTED TO FAIL BASED ON THE ISSUE) + expect($dates)->toContain('2024-03-28'); + + Carbon::setTestNow(); +}); + +test('iCal plugin parses recurring events with multiple BYDAY and specific DTSTART correctly', function (): void { + // Set test now to Monday 2024-03-25 + Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC')); + + $icalContent = <<<'ICS' +BEGIN:VCALENDAR +VERSION:2.0 +X-WR-TIMEZONE:UTC +PRODID:-//Example Corp.//EN +BEGIN:VEVENT +RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO +UID:recurring-event-2 +SUMMARY:Recurring Meeting 2 +DTSTART:20240326T100000 +DTEND:20240326T110000 +DTSTAMP:20240605T082436Z +END:VEVENT +END:VCALENDAR +ICS; + + Http::fake([ + 'example.com/recurring2.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/recurring2.ics', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + $plugin->refresh(); + + $ical = $plugin->data_payload['ical']; + $dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values(); + + expect($dates)->toContain('2024-03-26'); + expect($dates)->toContain('2024-03-28'); + + Carbon::setTestNow(); +});