diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php index 629b4fde13c..82a00171dfa 100644 --- a/apps/dav/lib/CalDAV/TipBroker.php +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -9,6 +9,7 @@ declare(strict_types=1); 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,154 +86,219 @@ 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']; + // Skip if SCHEDULE-AGENT=CLIENT (respect RFC 6638) + if ($this->scheduleAgentServerRules + && isset($eventInfo['attendees'][$attendee]['scheduleAgent']) + && strtoupper($eventInfo['attendees'][$attendee]['scheduleAgent']) === 'CLIENT') { + continue; + } - // Creating the new iCalendar body. - $icalMsg = new VCalendar(); + // detect if attendee was removed and send cancel message + if (!isset($eventInfo['attendees'][$attendee]) && isset($oldEventInfo['attendees'][$attendee])) { + //get all instances of the attendee was removed from. + $instances = array_intersect_key($oldEventInfo['instances'], array_flip(array_keys($oldEventInfo['attendees'][$attendee]['instances']))); + $messages[] = $this->generateMessage( + $instances, $organizerHref, $organizerName, $oldEventInfo['attendees'][$attendee], $objectId, $objectType, $objectSequence, 'CANCEL', $template + ); + continue; + } + // otherwise any created or modified instances will be sent as REQUEST + $instances = array_intersect_key($eventInfo['instances'], array_flip(array_keys($eventInfo['attendees'][$attendee]['instances']))); - foreach ($calendar->select('VTIMEZONE') as $timezone) { - $icalMsg->add(clone $timezone); + // Remove already-cancelled new instances from REQUEST + if (!empty($cancelledNewInstances)) { + $instances = array_diff_key($instances, array_flip($cancelledNewInstances)); } - // 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. - // Also If the meeting STATUS property was changed to CANCELLED - // we need to send the attendee a CANCEL message. - if (!$attendee['newInstances'] || $eventInfo['status'] === 'CANCELLED') { - - $message->method = $icalMsg->METHOD = 'CANCEL'; - $message->significantChange = true; - // clone base event - if (isset($eventInfo['instances']['master'])) { - $event = clone $eventInfo['instances']['master']; - } else { - $event = clone $oldEventInfo['instances']['master']; - } - // alter some properties - unset($event->ATTENDEE); - $event->add('ATTENDEE', $attendee['href'], ['CN' => $attendee['name'],]); - $event->DTSTAMP = gmdate('Ymd\\THis\\Z'); - $event->SEQUENCE = $message->sequence; - $icalMsg->add($event); - - } else { - // 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. - - $oldAttendeeInstances = array_keys($attendee['oldInstances']); - $newAttendeeInstances = array_keys($attendee['newInstances']); - - $message->significantChange - = $attendee['forceSend'] === 'REQUEST' - || count($oldAttendeeInstances) !== count($newAttendeeInstances) - || count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 - || $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; - - foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) { - $currentEvent = clone $eventInfo['instances'][$instanceId]; - if ($instanceId === 'master') { - // We need to find a list of events that the attendee - // is not a part of to add to the list of exceptions. - $exceptions = []; - foreach ($eventInfo['instances'] as $instanceId => $vevent) { - if (!isset($attendee['newInstances'][$instanceId])) { - $exceptions[] = $instanceId; - } - } - - // If there were exceptions, we need to add it to an - // existing EXDATE property, if it exists. - if ($exceptions) { - if (isset($currentEvent->EXDATE)) { - $currentEvent->EXDATE->setParts(array_merge( - $currentEvent->EXDATE->getParts(), - $exceptions - )); - } else { - $currentEvent->EXDATE = $exceptions; - } - } - - // Cleaning up any scheduling information that - // shouldn't be sent along. - unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']); - unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']); - - foreach ($currentEvent->ATTENDEE as $attendee) { - unset($attendee['SCHEDULE-FORCE-SEND']); - unset($attendee['SCHEDULE-STATUS']); - - // We're adding PARTSTAT=NEEDS-ACTION to ensure that - // iOS shows an "Inbox Item" - if (!isset($attendee['PARTSTAT'])) { - $attendee['PARTSTAT'] = 'NEEDS-ACTION'; - } - } + + // Skip if no instances left to send + if (empty($instances)) { + continue; + } + + // 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 = []; + + foreach ($eventInfo['instances'] as $instanceId => $instance) { + if ($instanceId !== 'master' && !isset($eventInfo['attendees'][$attendee]['instances'][$instanceId])) { + $excludedDates[] = $instance->{'RECURRENCE-ID'}->getValue(); } + } - $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 ddf992767d6..88438950125 100644 --- a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php +++ b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php @@ -15,11 +15,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 */ @@ -28,7 +36,7 @@ class TipBrokerTest extends TestCase { $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']); @@ -41,140 +49,534 @@ class TipBrokerTest extends TestCase { 'ROLE' => 'REQ-PARTICIPANT', 'RSVP' => 'TRUE' ]); - } - public function testParseEventForOrganizerOnCreate(): void { + // construct calendar with a 1 hour event and same start/end time zones + // recurring every week on Monday for 12 weeks + $this->vCalendar2a = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $this->vCalendar2a->add('VEVENT', []); + $vEvent->add('UID', '96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTAMP', '20240701T000000Z'); + $vEvent->add('CREATED', '20240701T000000Z'); + $vEvent->add('LAST-MODIFIED', '20240701T000000Z'); + $vEvent->add('SEQUENCE', 1); + $vEvent->add('STATUS', 'CONFIRMED'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('RRULE', 'FREQ=WEEKLY;COUNT=12;BYDAY=MO'); + $vEvent->add('SUMMARY', 'Test Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + } + /** + * 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 - $calendar = clone $this->vCalendar1a; - $previousEventInfo = [ - 'organizer' => null, - 'significantChangeHash' => '', - 'attendees' => [], - ]; - $currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); + $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($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); + } + /** + * 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); } - public function testParseEventForOrganizerOnModify(): void { + /** + * 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 - $calendar = clone $this->vCalendar1a; - $previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); - $calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); - $calendar->VEVENT->SEQUENCE->setValue(2); - $calendar->VEVENT->SUMMARY->setValue('Test Event Modified'); - $currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); - // test iTip generation - $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]); + $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($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender); + $this->assertEquals('mailto:attendee2@testing.com', $messages[1]->recipient); } - public function testParseEventForOrganizerOnDelete(): void { - // construct calendar and generate event info for modified event with one attendee - $calendar = clone $this->vCalendar1a; - $previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); - $currentEventInfo = $previousEventInfo; - $currentEventInfo['attendees'] = []; - ++$currentEventInfo['sequence']; + /** + * 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', [$calendar, $currentEventInfo, $previousEventInfo]); + $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($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); - $this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $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()); } - public function testParseEventForOrganizerOnStatusCancelled(): void { + /** + * 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()); + + } - // construct calendar and generate event info for modified event with one attendee - $calendar = clone $this->vCalendar1a; - $previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); - $calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); - $calendar->VEVENT->SEQUENCE->setValue(2); - $calendar->VEVENT->STATUS->setValue('CANCELLED'); - $currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); - // test iTip generation - $messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]); + /** + * 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('CANCEL', $messages[0]->method); - $this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender); - $this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); + $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()); } - public function testParseEventForOrganizerOnAddAttendee(): void { + /** + * 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 - $calendar = clone $this->vCalendar1a; - $previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); - $calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); - $calendar->VEVENT->SEQUENCE->setValue(2); - $calendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + $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' ]); - $currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $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]); + // 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(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($calendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender); - $this->assertEquals($calendar->VEVENT->ATTENDEE[1]->getValue(), $messages[1]->recipient); + $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()); } - public function testParseEventForOrganizerOnRemoveAttendee(): void { - + /** + * 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 - $calendar = clone $this->vCalendar1a; - $calendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + $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' ]); - $previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); - $calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); - $calendar->VEVENT->SEQUENCE->setValue(2); - $calendar->VEVENT->remove('ATTENDEE'); - $calendar->VEVENT->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + $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' ]); - $currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]); + $mutatedCalendar = clone $this->vCalendar2a; + $mutatedCalendar->add($mutatedInstance); + $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]); + // 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(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($calendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender); - $this->assertEquals('mailto:attendee2@testing.com', $messages[1]->recipient); + $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'])); } }