diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php index 43eff124f0bb1..0a319863ca376 100644 --- a/apps/dav/lib/CalDAV/TipBroker.php +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -9,6 +9,7 @@ namespace OCA\DAV\CalDAV; +use Sabre\VObject\Component; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\ITip\Broker; use Sabre\VObject\ITip\Message; @@ -27,9 +28,56 @@ class TipBroker extends Broker { 'SUMMARY', 'DESCRIPTION', 'LOCATION', - ]; + /** + * Processes incoming CANCEL messages. + * + * This is a message from an organizer, and means that either an + * attendee got removed from an event, or an event got cancelled + * altogether. + * + * @param VCalendar $existingObject + * + * @return VCalendar|null + */ + protected function processMessageCancel(Message $itipMessage, ?VCalendar $existingObject = null) { + if ($existingObject === null) { + return null; + } + + $componentType = $itipMessage->component; + $instances = []; + + foreach ($itipMessage->message->$componentType as $component) { + $instanceId = isset($component->{'RECURRENCE-ID'}) ? $component->{'RECURRENCE-ID'}->getValue() : 'base'; + $instances[$instanceId] = $component; + } + // any existing instances should be marked as cancelled + foreach ($existingObject->$componentType as $component) { + $instanceId = isset($component->{'RECURRENCE-ID'}) ? $component->{'RECURRENCE-ID'}->getValue() : 'base'; + if (isset($instances[$instanceId])) { + if (isset($component->STATUS)) { + $component->STATUS->setValue('CANCELLED'); + } else { + $component->add('STATUS', 'CANCELLED'); + } + if (isset($component->SEQUENCE)) { + $component->SEQUENCE->setValue($itipMessage->sequence); + } else { + $component->add('SEQUENCE', $itipMessage->sequence); + } + unset($instances[$instanceId]); + } + } + // any remaining instances are new and should be added + foreach ($instances as $instance) { + $existingObject->add($instance); + } + + return $existingObject; + } + /** * This method is used in cases where an event got updated, and we * potentially need to send emails to attendees to let them know of updates @@ -38,59 +86,87 @@ class TipBroker extends Broker { * We will detect which attendees got added, which got removed and create * specific messages for these situations. * - * @return array + * @return array */ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) { - // Merging attendee lists. - $attendees = []; - foreach ($oldEventInfo['attendees'] as $attendee) { - $attendees[$attendee['href']] = [ - 'href' => $attendee['href'], - 'oldInstances' => $attendee['instances'], - 'newInstances' => [], - 'name' => $attendee['name'], - 'forceSend' => null, - ]; - } - foreach ($eventInfo['attendees'] as $attendee) { - if (isset($attendees[$attendee['href']])) { - $attendees[$attendee['href']]['name'] = $attendee['name']; - $attendees[$attendee['href']]['newInstances'] = $attendee['instances']; - $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend']; - } else { - $attendees[$attendee['href']] = [ - 'href' => $attendee['href'], - 'oldInstances' => [], - 'newInstances' => $attendee['instances'], - 'name' => $attendee['name'], - 'forceSend' => $attendee['forceSend'], - ]; - } - } $messages = []; + // construct template calendar from original calendar without components + $template = new VCalendar(); + foreach ($template->children() as $property) { + $template->remove($property); + } + foreach ($calendar->children() as $property) { + if (in_array($property->name, ['METHOD', 'VEVENT', 'VTODO', 'VJOURNAL', 'VFREEBUSY'], true) === false) { + $template->add(clone $property); + } + } + // extract event information + $objectId = $eventInfo['uid']; + if ($calendar->getBaseComponent() === null) { + $objectType = $calendar->getComponents()[0]->name; + } else { + $objectType = $calendar->getBaseComponent()->name; + } + $objectSequence = $eventInfo['sequence'] ?? 1; + $organizerHref = $eventInfo['organizer'] ?? $oldEventInfo['organizer']; + if ($eventInfo['organizerName'] instanceof \Sabre\VObject\Parameter) { + $organizerName = $eventInfo['organizerName']->getValue(); + } else { + $organizerName = $eventInfo['organizerName']; + } + // detect if the singleton or recurring base instance was converted to non-scheduling + if (count($eventInfo['instances']) === 0 && count($oldEventInfo['instances']) > 0) { + foreach ($oldEventInfo['attendees'] as $attendee) { + $messages[] = $this->generateMessage( + $oldEventInfo['instances'], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template + ); + } + return $messages; + } + // detect if the singleton or recurring base instance was cancelled + if ($eventInfo['instances']['master']?->STATUS?->getValue() === 'CANCELLED' && $oldEventInfo['instances']['master']?->STATUS?->getValue() !== 'CANCELLED') { + foreach ($eventInfo['attendees'] as $attendee) { + $messages[] = $this->generateMessage( + $eventInfo['instances'], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template + ); + } + return $messages; + } + // detect if a new cancelled instance was created + $cancelledNewInstances = []; + if (isset($oldEventInfo['instances'])) { + $instancesDelta = array_diff_key($eventInfo['instances'], $oldEventInfo['instances']); + foreach ($instancesDelta as $id => $instance) { + if ($instance->STATUS?->getValue() === 'CANCELLED') { + $cancelledNewInstances[] = $id; + foreach ($eventInfo['attendees'] as $attendee) { + $messages[] = $this->generateMessage( + [$id => $instance], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template + ); + } + } + } + } + // detect attendee mutations + $attendees = array_unique( + array_merge( + array_keys($eventInfo['attendees']), + array_keys($oldEventInfo['attendees']) + ) + ); foreach ($attendees as $attendee) { - // An organizer can also be an attendee. We should not generate any - // messages for those. - if ($attendee['href'] === $eventInfo['organizer']) { + // Skip organizer + if ($attendee === $organizerHref) { continue; } - $message = new Message(); - $message->uid = $eventInfo['uid']; - $message->component = 'VEVENT'; - $message->sequence = $eventInfo['sequence']; - $message->sender = $eventInfo['organizer']; - $message->senderName = $eventInfo['organizerName']; - $message->recipient = $attendee['href']; - $message->recipientName = $attendee['name']; - - // Creating the new iCalendar body. - $icalMsg = new VCalendar(); - - foreach ($calendar->select('VTIMEZONE') as $timezone) { - $icalMsg->add(clone $timezone); + // Skip if SCHEDULE-AGENT=CLIENT (respect RFC 6638) + if ($this->scheduleAgentServerRules + && isset($eventInfo['attendees'][$attendee]['scheduleAgent']) + && strtoupper($eventInfo['attendees'][$attendee]['scheduleAgent']) === 'CLIENT') { + continue; } // If there are no instances the attendee is a part of, it means // the attendee was removed and we need to send them a CANCEL message. @@ -113,16 +189,15 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, // The attendee gets the updated event body $message->method = $icalMsg->METHOD = 'REQUEST'; - // We need to find out that this change is significant. If it's - // not, systems may opt to not send messages. - // - // We do this based on the 'significantChangeHash' which is - // some value that changes if there's a certain set of - // properties changed in the event, or simply if there's a - // difference in instances that the attendee is invited to. + // Skip if no instances left to send + if (empty($instances)) { + continue; + } - $oldAttendeeInstances = array_keys($attendee['oldInstances']); - $newAttendeeInstances = array_keys($attendee['newInstances']); + // Add EXDATE for instances the attendee is NOT part of (only for recurring events with master) + if (isset($instances['master']) && count($eventInfo['instances']) > 1) { + $masterInstance = clone $instances['master']; + $excludedDates = []; $message->significantChange = $attendee['forceSend'] === 'REQUEST' || @@ -171,17 +246,105 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, } } } + } - $currentEvent->DTSTAMP = gmdate('Ymd\\THis\\Z'); - $icalMsg->add($currentEvent); + if (!empty($excludedDates)) { + if (isset($masterInstance->EXDATE)) { + $currentExdates = $masterInstance->EXDATE->getParts(); + $masterInstance->EXDATE->setParts(array_merge($currentExdates, $excludedDates)); + } else { + $masterInstance->EXDATE = $excludedDates; + } + $instances['master'] = $masterInstance; } } - $message->message = $icalMsg; - $messages[] = $message; + $messages[] = $this->generateMessage( + $instances, $organizerHref, $organizerName, $eventInfo['attendees'][$attendee], $objectId, $objectType, $objectSequence, 'REQUEST', $template + ); } return $messages; } + /** + * Generates an iTip message for a specific attendee + * + * @param array $instances Array of event instances to include, keyed by instance ID: + * - 'master' => Component: The master/base event + * - '{RECURRENCE-ID}' => Component: Exception instances + * @param string $organizerHref The organizer's calendar-user address (e.g., 'mailto:user@example.com') + * @param string|null $organizerName The organizer's display name + * @param array $attendee The attendee information containing: + * - 'href' (string): The attendee's calendar-user address + * - 'name' (string): The attendee's display name + * - 'scheduleAgent' (string|null): SCHEDULE-AGENT parameter + * - 'instances' (array): Instances this attendee is part of + * @param string $objectId The UID of the event + * @param string $objectType The component type ('VEVENT', 'VTODO', etc.) + * @param int $objectSequence The sequence number of the event + * @param string $method The iTip method ('REQUEST', 'CANCEL', 'REPLY', etc.) + * @param VCalendar $template The template calendar object (without event components) + * @return Message The generated iTip message ready to be sent + */ + protected function generateMessage( + array $instances, + string $organizerHref, + ?string $organizerName, + array $attendee, + string $objectId, + string $objectType, + int $objectSequence, + string $method, + VCalendar $template, + ): Message { + + $recipientAddress = $attendee['href'] ?? ''; + $recipientName = $attendee['name'] ?? ''; + + $vObject = clone $template; + if ($vObject->METHOD && $vObject->METHOD->getValue() !== $method) { + $vObject->METHOD->setValue($method); + } else { + $vObject->add('METHOD', $method); + } + foreach ($instances as $instance) { + $vObject->add($this->componentSanitizeScheduling(clone $instance)); + } + + $message = new Message(); + $message->method = $method; + $message->uid = $objectId; + $message->component = $objectType; + $message->sequence = $objectSequence; + $message->sender = $organizerHref; + $message->senderName = $organizerName; + $message->recipient = $recipientAddress; + $message->recipientName = $recipientName; + $message->significantChange = true; + $message->message = $vObject; + + return $message; + + } + + protected function componentSanitizeScheduling(Component $component): Component { + // Cleaning up any scheduling information that should not be sent or is missing + unset($component->ORGANIZER['SCHEDULE-FORCE-SEND'], $component->ORGANIZER['SCHEDULE-STATUS']); + foreach ($component->ATTENDEE as $attendee) { + unset($attendee['SCHEDULE-FORCE-SEND'], $attendee['SCHEDULE-STATUS']); + + if (!isset($attendee['PARTSTAT'])) { + $attendee['PARTSTAT'] = 'NEEDS-ACTION'; + } + } + // Sequence is a required property, default is 0 + // https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.7.4 + if ($component->SEQUENCE === null) { + $component->add('SEQUENCE', 0); + } + + return $component; + } + } diff --git a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php index ee8edea560c23..0f61d4ee34762 100644 --- a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php +++ b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php @@ -14,11 +14,19 @@ class TipBrokerTest extends TestCase { private TipBroker $broker; private VCalendar $vCalendar1a; + private VCalendar $vCalendar2a; + private array $templateEventInfo; protected function setUp(): void { parent::setUp(); $this->broker = new TipBroker(); + + $this->templateEventInfo = [ + 'organizer' => null, + 'attendees' => [], + 'significantChangeHash' => '', + ]; // construct calendar with a 1 hour event and same start/end time zones $this->vCalendar1a = new VCalendar(); /** @var VEvent $vEvent */ @@ -27,7 +35,7 @@ protected function setUp(): void { $vEvent->add('DTSTAMP', '20240701T000000Z'); $vEvent->add('CREATED', '20240701T000000Z'); $vEvent->add('LAST-MODIFIED', '20240701T000000Z'); - $vEvent->add('SEQUENCE', '1'); + $vEvent->add('SEQUENCE', 1); $vEvent->add('STATUS', 'CONFIRMED'); $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); @@ -40,7 +48,6 @@ protected function setUp(): void { 'ROLE' => 'REQ-PARTICIPANT', 'RSVP' => 'TRUE' ]); - } public function testParseEventForOrganizerOnCreate(): void { @@ -163,17 +170,510 @@ public function testParseEventForOrganizerOnRemoveAttendee(): void { 'ROLE' => 'REQ-PARTICIPANT', 'RSVP' => 'TRUE' ]); - $currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); + } + + /** + * Tests user creating a new singleton or recurring event + */ + public function testParseEventForOrganizerCreated(): void { + // construct calendar and generate event info for newly created event with one attendee + $mutatedCalendar = clone $this->vCalendar1a; + $originalEventInfo = $this->templateEventInfo; + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); // test iTip generation - $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]); + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + } + + /** + * Tests user modifying an existing singleton or recurring (base) event + */ + public function testParseEventForOrganizerModified(): void { + // construct calendar and generate event info for modified event with one attendee + $originalCalendar = clone $this->vCalendar1a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedCalendar = clone $this->vCalendar1a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->SUMMARY->setValue('Test Event Modified'); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + } + + /** + * Tests user deleting an existing singleton or recurring (base) event + */ + public function testParseEventForOrganizerDeleted(): void { + // construct calendar and generate event info for modified event with one attendee + $originalCalendar = clone $this->vCalendar1a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedEventInfo = $originalEventInfo; + $mutatedEventInfo['attendees'] = []; + ++$mutatedEventInfo['sequence']; + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$originalCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('CANCEL', $messages[0]->method); + $this->assertEquals($originalCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($originalCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + } + + /** + * Tests user cancelling an existing singleton or recurring (base) event + */ + public function testParseEventForOrganizerStatusCancelled(): void { + // construct calendar and generate event info for modified event with one attendee + $originalCalendar = clone $this->vCalendar1a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedCalendar = clone $this->vCalendar1a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->STATUS->setValue('CANCELLED'); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('CANCEL', $messages[0]->method); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + } + + /** + * Tests user adding an attendee to an existing singleton or recurring (base) event + */ + public function testParseEventForOrganizerAddAttendee(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar1a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedCalendar = clone $this->vCalendar1a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + 'CN' => 'Attendee Two', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing $this->assertCount(2, $messages); $this->assertEquals('REQUEST', $messages[0]->method); - $this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); - $this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertEquals('REQUEST', $messages[1]->method); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[1]->getValue(), $messages[1]->recipient); + } + + /** + * Tests user removing an attendee from an existing singleton or recurring (base) event + */ + public function testParseEventForOrganizerRemoveAttendee(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar1a; + $originalCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + 'CN' => 'Attendee Two', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedCalendar = clone $this->vCalendar1a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->remove('ATTENDEE'); + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(2, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); $this->assertEquals('CANCEL', $messages[1]->method); - $this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender); $this->assertEquals('mailto:attendee2@testing.com', $messages[1]->recipient); + } + + /** + * Tests user converts existing singleton or recurring (base) event from attended to attendeless + */ + public function testParseEventForOrganizerRemoveOrganizerAndAttendees(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar1a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedCalendar = clone $this->vCalendar1a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->remove('ORGANIZER'); + $mutatedCalendar->VEVENT->remove('ATTENDEE'); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(1, $messages); + $this->assertEquals('CANCEL', $messages[0]->method); + $this->assertEquals(2, $messages[0]->sequence); + $this->assertEquals($originalCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($originalCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + } + + /** + * Tests user modifying recurring (base) event by moving instance to a new date + */ + public function testParseEventForOrganizerCreatedInstance(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar2a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedInstance = clone $originalCalendar->VEVENT; + $mutatedInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $mutatedInstance->SEQUENCE->setValue(0); + $mutatedInstance->DTSTART->setValue('20240717T080000'); + $mutatedInstance->DTEND->setValue('20240717T090000'); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(1, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertCount(2, $messages[0]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[0]->message->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + + } + + /** + * Tests user modifying recurring (base) event by cancelling a single instance + */ + public function testParseEventForOrganizerCreatedInstanceCancelled(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar2a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedInstance = clone $originalCalendar->VEVENT; + $mutatedInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $mutatedInstance->SEQUENCE->setValue(0); + $mutatedInstance->STATUS->setValue('CANCELLED'); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(2, $messages); + $this->assertEquals('CANCEL', $messages[0]->method); + $this->assertEquals(1, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertCount(1, $messages[0]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[0]->message->VEVENT->{'RECURRENCE-ID'}->getValue()); + + } + + /** + * Tests user modifying recurring (instance) event with non status or attendee changes + */ + public function testParseEventForOrganizerModifyInstance(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar2a; + $originalInstance = clone $originalCalendar->VEVENT; + $originalInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $originalInstance->SEQUENCE->setValue(1); + $originalInstance->DTSTART->setValue('20240717T080000'); + $originalInstance->DTEND->setValue('20240717T090000'); + $originalCalendar->add($originalInstance); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + + $mutatedInstance = clone $originalInstance; + $mutatedInstance->SEQUENCE->setValue(2); + $mutatedInstance->DTSTART->setValue('20240718T080000'); + $mutatedInstance->DTEND->setValue('20240718T090000'); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(1, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertCount(2, $messages[0]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[0]->message->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + + } + + /** + * Tests user modifying recurring (instance) event by setting status to cancelled + */ + public function testParseEventForOrganizerModifyInstanceStatus(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar2a; + $originalInstance = clone $originalCalendar->VEVENT; + $originalInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $originalInstance->SEQUENCE->setValue(1); + $originalInstance->DTSTART->setValue('20240717T080000'); + $originalInstance->DTEND->setValue('20240717T090000'); + $originalCalendar->add($originalInstance); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedInstance = clone $originalInstance; + $mutatedInstance->SEQUENCE->setValue(2); + $mutatedInstance->STATUS->setValue('CANCELLED'); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(1, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertCount(2, $messages[0]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[0]->message->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + + } + + /** + * Tests user modifying recurring (instance) event by adding attendee + */ + public function testParseEventForOrganizerModifyInstanceAddAttendee(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar2a; + $originalInstance = clone $originalCalendar->VEVENT; + $originalInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $originalInstance->SEQUENCE->setValue(1); + $originalInstance->DTSTART->setValue('20240717T080000'); + $originalInstance->DTEND->setValue('20240717T090000'); + $originalCalendar->add($originalInstance); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedInstance = clone $originalInstance; + $mutatedInstance->SEQUENCE->setValue(2); + $mutatedInstance->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + 'CN' => 'Attendee Two', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(2, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(1, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertCount(2, $messages[0]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[0]->message->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + $this->assertEquals('REQUEST', $messages[1]->method); + $this->assertEquals(1, $messages[1]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ORGANIZER->getValue(), $messages[1]->sender); + $this->assertEquals($mutatedCalendar->VEVENT[1]->ATTENDEE[1]->getValue(), $messages[1]->recipient); + $this->assertCount(1, $messages[1]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[1]->message->VEVENT->{'RECURRENCE-ID'}->getValue()); + + } + + /** + * Tests user modifying recurring (instance) event by removing attendee + */ + public function testParseEventForOrganizerModifyInstanceRemoveAttendee(): void { + // construct calendar and generate event info for modified event with two attendees + $originalCalendar = clone $this->vCalendar2a; + $originalInstance = clone $originalCalendar->VEVENT; + $originalInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $originalInstance->SEQUENCE->setValue(1); + $originalInstance->DTSTART->setValue('20240717T080000'); + $originalInstance->DTEND->setValue('20240717T090000'); + $originalInstance->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + 'CN' => 'Attendee Two', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $originalCalendar->add($originalInstance); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedInstance = clone $originalInstance; + $mutatedInstance->SEQUENCE->setValue(2); + $mutatedInstance->remove('ATTENDEE'); + $mutatedInstance->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + // attendee modifications get generated in order of Added, Removed, Existing + $this->assertCount(2, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(1, $messages[0]->sequence); + $this->assertEquals($originalCalendar->VEVENT[1]->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($originalCalendar->VEVENT[1]->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $this->assertCount(2, $messages[0]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[0]->message->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + $this->assertEquals('CANCEL', $messages[1]->method); + $this->assertEquals(1, $messages[1]->sequence); + $this->assertEquals($originalCalendar->VEVENT[1]->ORGANIZER->getValue(), $messages[1]->sender); + $this->assertEquals($originalCalendar->VEVENT[1]->ATTENDEE[1]->getValue(), $messages[1]->recipient); + $this->assertCount(1, $messages[1]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[1]->message->VEVENT->{'RECURRENCE-ID'}->getValue()); + + } + + /** + * Tests user deleting master instance of recurring event + */ + public function testParseEventForOrganizerDeleteMasterInstance(): void { + // construct calendar with recurring event + $originalCalendar = clone $this->vCalendar2a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + // delete the master instance (convert to non-scheduling) + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->remove('ORGANIZER'); + $mutatedCalendar->VEVENT->remove('ATTENDEE'); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('CANCEL', $messages[0]->method); + $this->assertEquals(2, $messages[0]->sequence); + $this->assertEquals($originalCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($originalCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + } + + /** + * Tests user adding EXDATE to master instance + */ + public function testParseEventForOrganizerAddExdate(): void { + // construct calendar with recurring event + $originalCalendar = clone $this->vCalendar2a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + // add EXDATE to exclude specific occurrences + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->add('EXDATE', ['20240715T080000', '20240722T080000'], ['TZID' => 'America/Toronto']); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(2, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + // verify EXDATE is present in the message + $this->assertTrue(isset($messages[0]->message->VEVENT->EXDATE)); + $exdates = $messages[0]->message->VEVENT->EXDATE->getParts(); + $this->assertContains('20240715T080000', $exdates); + $this->assertContains('20240722T080000', $exdates); + } + + /** + * Tests user removing EXDATE from master instance + */ + public function testParseEventForOrganizerRemoveExdate(): void { + // construct calendar with recurring event that has EXDATE + $originalCalendar = clone $this->vCalendar2a; + $originalCalendar->VEVENT->add('EXDATE', ['20240715T080000', '20240722T080000'], ['TZID' => 'America/Toronto']); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + // remove EXDATE to restore excluded occurrences + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(2, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + // verify EXDATE is not present in the message + $this->assertFalse(isset($messages[0]->message->VEVENT->EXDATE)); + } + + /** + * Tests user converting recurring event to non-scheduling + */ + public function testParseEventForOrganizerConvertRecurringToNonScheduling(): void { + // construct calendar with recurring event + $originalCalendar = clone $this->vCalendar2a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + // remove ORGANIZER and ATTENDEE properties to convert to non-scheduling + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->remove('ORGANIZER'); + $mutatedCalendar->VEVENT->remove('ATTENDEE'); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('CANCEL', $messages[0]->method); + $this->assertEquals(2, $messages[0]->sequence); + $this->assertEquals($originalCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($originalCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + } + + /** + * Tests SCHEDULE-FORCE-SEND parameter handling + */ + public function testParseEventForOrganizerScheduleForceSend(): void { + // construct calendar with event + $originalCalendar = clone $this->vCalendar1a; + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + // add SCHEDULE-FORCE-SEND parameter to ATTENDEE + $mutatedCalendar = clone $this->vCalendar1a; + $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); + $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); + $mutatedCalendar->VEVENT->ATTENDEE->add('SCHEDULE-FORCE-SEND', 'REQUEST'); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + // test iTip generation + $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]); + $this->assertCount(1, $messages); + $this->assertEquals('REQUEST', $messages[0]->method); + $this->assertEquals(2, $messages[0]->sequence); + $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); + $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE->getValue(), $messages[0]->recipient); + // verify SCHEDULE-FORCE-SEND is removed from the message (sanitized) + $this->assertFalse(isset($messages[0]->message->VEVENT->ATTENDEE['SCHEDULE-FORCE-SEND'])); } }