From 2570cebd1a3e35b286506d719182990926888ab9 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Mon, 21 Sep 2026 11:03:21 -0400 Subject: [PATCH] fix(dav): pair changed iTip instances by UID/RECURRENCE-ID instead of array position Signed-off-by: SebastianKrupinski --- .../composer/composer/autoload_classmap.php | 1 - .../dav/composer/composer/autoload_static.php | 1 - .../dav/lib/CalDAV/EventComparisonService.php | 100 -------- apps/dav/lib/CalDAV/Schedule/IMipPlugin.php | 45 ++-- apps/dav/lib/CalDAV/Schedule/IMipService.php | 71 ++++++ apps/dav/lib/Server.php | 2 - .../CalDAV/EventComparisonServiceTest.php | 187 -------------- .../CalDAV/Schedule/IMipPluginCharsetTest.php | 6 - .../unit/CalDAV/Schedule/IMipPluginTest.php | 197 ++++++++++++--- .../unit/CalDAV/Schedule/IMipServiceTest.php | 238 ++++++++++++++++++ 10 files changed, 497 insertions(+), 351 deletions(-) delete mode 100644 apps/dav/lib/CalDAV/EventComparisonService.php delete mode 100644 apps/dav/tests/unit/CalDAV/EventComparisonServiceTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 310f1ec83be3d..6ee527f35976d 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -63,7 +63,6 @@ 'OCA\\DAV\\CalDAV\\CalendarRoot' => $baseDir . '/../lib/CalDAV/CalendarRoot.php', 'OCA\\DAV\\CalDAV\\DefaultCalendarValidator' => $baseDir . '/../lib/CalDAV/DefaultCalendarValidator.php', 'OCA\\DAV\\CalDAV\\EmbeddedCalDavServer' => $baseDir . '/../lib/CalDAV/EmbeddedCalDavServer.php', - 'OCA\\DAV\\CalDAV\\EventComparisonService' => $baseDir . '/../lib/CalDAV/EventComparisonService.php', 'OCA\\DAV\\CalDAV\\EventReader' => $baseDir . '/../lib/CalDAV/EventReader.php', 'OCA\\DAV\\CalDAV\\EventReaderRDate' => $baseDir . '/../lib/CalDAV/EventReaderRDate.php', 'OCA\\DAV\\CalDAV\\EventReaderRRule' => $baseDir . '/../lib/CalDAV/EventReaderRRule.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 98a49d46284dd..fcf34a2985532 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -78,7 +78,6 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\CalendarRoot' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarRoot.php', 'OCA\\DAV\\CalDAV\\DefaultCalendarValidator' => __DIR__ . '/..' . '/../lib/CalDAV/DefaultCalendarValidator.php', 'OCA\\DAV\\CalDAV\\EmbeddedCalDavServer' => __DIR__ . '/..' . '/../lib/CalDAV/EmbeddedCalDavServer.php', - 'OCA\\DAV\\CalDAV\\EventComparisonService' => __DIR__ . '/..' . '/../lib/CalDAV/EventComparisonService.php', 'OCA\\DAV\\CalDAV\\EventReader' => __DIR__ . '/..' . '/../lib/CalDAV/EventReader.php', 'OCA\\DAV\\CalDAV\\EventReaderRDate' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRDate.php', 'OCA\\DAV\\CalDAV\\EventReaderRRule' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRRule.php', diff --git a/apps/dav/lib/CalDAV/EventComparisonService.php b/apps/dav/lib/CalDAV/EventComparisonService.php deleted file mode 100644 index 345d1785ee8e4..0000000000000 --- a/apps/dav/lib/CalDAV/EventComparisonService.php +++ /dev/null @@ -1,100 +0,0 @@ - $eventToFilter) { - $eventToFilterData = []; - foreach (self::EVENT_DIFF as $eventDiff) { - $eventToFilterData[] = IMipService::readPropertyWithDefault($eventToFilter, $eventDiff, ''); - } - // events are identical and can be removed - if ($filterEventData === $eventToFilterData) { - unset($eventsToFilter[$k]); - return true; - } - } - return false; - } - - /** - * Compare two VCalendars with each other and find all changed elements - * - * Returns an array of old and new events - * - * Old events are only detected if they are also changed - * If there is no corresponding old event for a VEvent, it - * has been newly created - * - * @param VCalendar $new - * @param VCalendar|null $old - * @return array - */ - public function findModified(VCalendar $new, ?VCalendar $old): array { - $newEventComponents = $new->getComponents(); - - foreach ($newEventComponents as $k => $event) { - if (!$event instanceof VEvent) { - unset($newEventComponents[$k]); - } - } - - if (empty($old)) { - return ['old' => null, 'new' => $newEventComponents]; - } - - $oldEventComponents = $old->getComponents(); - if (is_array($oldEventComponents) && !empty($oldEventComponents)) { - foreach ($oldEventComponents as $k => $event) { - if (!$event instanceof VEvent) { - unset($oldEventComponents[$k]); - continue; - } - if ($this->removeIfUnchanged($event, $newEventComponents)) { - unset($oldEventComponents[$k]); - } - } - } - - return ['old' => array_values($oldEventComponents), 'new' => array_values($newEventComponents)]; - } -} diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 006f820e4f7b7..dd475fff6af81 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -10,7 +10,6 @@ namespace OCA\DAV\CalDAV\Schedule; use OCA\DAV\CalDAV\CalendarObject; -use OCA\DAV\CalDAV\EventComparisonService; use OCP\Accounts\IAccountManager; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Defaults; @@ -65,7 +64,6 @@ public function __construct( private Defaults $defaults, private IUserSession $userSession, private IMipService $imipService, - private EventComparisonService $eventComparisonService, private IMailManager $mailManager, private IEmailValidator $emailValidator, private IAccountManager $accountManager, @@ -149,26 +147,45 @@ public function schedule(Message $iTipMessage) { $newEvents = $iTipMessage->message; $oldEvents = $this->getVCalendar(); - $modified = $this->eventComparisonService->findModified($newEvents, $oldEvents); - /** @var VEvent $vEvent */ - $vEvent = array_pop($modified['new']); - /** @var VEvent $oldVevent */ - $oldVevent = !empty($modified['old']) && is_array($modified['old']) ? array_pop($modified['old']) : null; - $isModified = isset($oldVevent); + $modifiedInstances = $this->imipService->findModifiedInstances($newEvents, $oldEvents); // No changed events after all - this shouldn't happen if there is significant change yet here we are // The scheduling status is debatable - if (empty($vEvent)) { + if (empty($modifiedInstances)) { $this->logger->warning('iTip message said the change was significant but comparison did not detect any updated VEvents'); $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; return; } - // we (should) have one event component left - // as the ITip\Broker creates one iTip message per change - // and triggers the "schedule" event once per message - // we also might not have an old event as this could be a new - // invitation, or a new recurrence exception + // we (should) have one changed instance per message, as the ITip\Broker + // creates one iTip message per attendee and triggers the "schedule" + // event once per message; a message can still bundle several changed + // instances (e.g. master + overrides edited together), in which case + // only the primary instance is reflected in the email for now + $primaryInstance = null; + foreach ($modifiedInstances as $instance) { + if (!isset($instance['new']->{'RECURRENCE-ID'})) { + $primaryInstance = $instance; + break; + } + } + $primaryInstance ??= $modifiedInstances[0]; + + if (count($modifiedInstances) > 1) { + $this->logger->debug('iTip message contains multiple changed instances; only the master/primary instance is reflected in the invitation email', [ + 'uid' => $iTipMessage->uid, + 'instanceCount' => count($modifiedInstances), + ]); + } + + /** @var VEvent $vEvent */ + $vEvent = $primaryInstance['new']; + /** @var VEvent|null $oldVevent */ + $oldVevent = $primaryInstance['old']; + $isModified = $oldVevent !== null; + + // we might not have an old event as this could be a new invitation, + // or a new recurrence exception $attendee = $this->imipService->getCurrentAttendee($iTipMessage); if ($attendee === null) { $uid = $vEvent->UID ?? 'no UID found'; diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php index 1e3a7d1892fbd..1826c01b1a1d0 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipService.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -40,6 +40,18 @@ class IMipService { 'meeting_location' => 'LOCATION' ]; + /** + * Properties that, besides RECURRENCE-ID, decide whether a matched + * instance pair counts as "changed". + * + * @var string[] + */ + private const array INSTANCE_DIFF_PROPERTIES = [ + 'RRULE', + 'SEQUENCE', + 'LAST-MODIFIED', + ]; + public function __construct( private URLGenerator $urlGenerator, private IDBConnection $db, @@ -149,6 +161,65 @@ private function linkify(?string $url): ?string { return sprintf('%1$s', htmlspecialchars($url)); } + /** + * Compares $new against the previously stored $old calendar and returns + * every VEVENT that changed, paired with its previous version. + * + * Instances are matched by UID and RECURRENCE-ID (the empty string + * standing in for the master event), never by array position, so a + * message carrying several instances (master plus overrides) pairs each + * one with its own prior version rather than an arbitrary old entry. + * A new instance with no matching old one is paired with null. + * + * @return list + */ + public function findModifiedInstances(VCalendar $new, ?VCalendar $old): array { + $newEvents = array_values(array_filter( + $new->getComponents(), + static fn ($component) => $component instanceof VEvent, + )); + + if ($old === null) { + return array_map( + static fn (VEvent $event) => ['new' => $event, 'old' => null], + $newEvents, + ); + } + + $oldEventsByInstance = []; + foreach ($old->getComponents() as $component) { + if ($component instanceof VEvent) { + $oldEventsByInstance[$this->instanceKey($component)] = $component; + } + } + + $modified = []; + foreach ($newEvents as $newEvent) { + $oldEvent = $oldEventsByInstance[$this->instanceKey($newEvent)] ?? null; + + if ($oldEvent !== null && $this->isInstanceUnchanged($newEvent, $oldEvent)) { + continue; + } + + $modified[] = ['new' => $newEvent, 'old' => $oldEvent]; + } + + return $modified; + } + + private function instanceKey(VEvent $event): string { + return self::readPropertyWithDefault($event, 'UID', '') . '#' . self::readPropertyWithDefault($event, 'RECURRENCE-ID', ''); + } + + private function isInstanceUnchanged(VEvent $newEvent, VEvent $oldEvent): bool { + foreach (self::INSTANCE_DIFF_PROPERTIES as $property) { + if (self::readPropertyWithDefault($newEvent, $property, '') !== self::readPropertyWithDefault($oldEvent, $property, '')) { + return false; + } + } + return true; + } + /** * @param VEvent $vEvent * @param VEvent|null $oldVEvent diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index ce5964e1c10f7..3c143264c36ae 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -14,7 +14,6 @@ use OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin; use OCA\DAV\CalDAV\BirthdayService; use OCA\DAV\CalDAV\DefaultCalendarValidator; -use OCA\DAV\CalDAV\EventComparisonService; use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin; use OCA\DAV\CalDAV\Publishing\PublishPlugin; use OCA\DAV\CalDAV\Schedule\IMipPlugin; @@ -358,7 +357,6 @@ public function __construct( \OCP\Server::get(Defaults::class), $userSession, \OCP\Server::get(IMipService::class), - \OCP\Server::get(EventComparisonService::class), \OCP\Server::get(\OCP\Mail\Provider\IManager::class), \OCP\Server::get(IEmailValidator::class), \OCP\Server::get(IAccountManager::class), diff --git a/apps/dav/tests/unit/CalDAV/EventComparisonServiceTest.php b/apps/dav/tests/unit/CalDAV/EventComparisonServiceTest.php deleted file mode 100644 index 37bab8c5fe083..0000000000000 --- a/apps/dav/tests/unit/CalDAV/EventComparisonServiceTest.php +++ /dev/null @@ -1,187 +0,0 @@ -eventComparisonService = new EventComparisonService(); - } - - public function testNoModifiedEvent(): void { - $vCalendarOld = new VCalendar(); - $vCalendarNew = new VCalendar(); - - $vEventOld = $vCalendarOld->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 2, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', - ]); - $vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventOld->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $vEventNew = $vCalendarNew->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 2, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', - ]); - $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); - $this->assertEmpty($result['old']); - $this->assertEmpty($result['new']); - } - - public function testNewEvent(): void { - $vCalendarOld = null; - $vCalendarNew = new VCalendar(); - - $vEventNew = $vCalendarNew->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 2, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', - ]); - $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); - $this->assertNull($result['old']); - $this->assertEquals([$vEventNew], $result['new']); - } - - public function testModifiedUnmodifiedEvent(): void { - $vCalendarOld = new VCalendar(); - $vCalendarNew = new VCalendar(); - - $vEventOld1 = $vCalendarOld->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 2, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - ]); - $vEventOld1->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventOld1->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $vEventOld2 = $vCalendarOld->add('VEVENT', [ - 'UID' => 'uid-1235', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 2, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - ]); - $vEventOld2->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventOld2->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $vEventNew1 = $vCalendarNew->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 2, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - ]); - $vEventNew1->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventNew1->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $vEventNew2 = $vCalendarNew->add('VEVENT', [ - 'UID' => 'uid-1235', - 'LAST-MODIFIED' => 123457, - 'SEQUENCE' => 3, - 'SUMMARY' => 'Fellowship meeting 2', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - ]); - $vEventNew2->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventNew2->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); - $this->assertEquals([$vEventOld2], $result['old']); - $this->assertEquals([$vEventNew2], $result['new']); - } - - // First test to certify fix for issue nextcloud/server#41084 - public function testSequenceNumberIncrementDetectedForFirstModificationToEventWithoutZeroInit(): void { - $vCalendarOld = new VCalendar(); - $vCalendarNew = new VCalendar(); - - $vEventOld = $vCalendarOld->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - // 'SEQUENCE' => 0, // sequence number may not be set to zero during event creation and instead fully omitted - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', - ]); - $vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventOld->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $vEventNew = $vCalendarNew->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 1, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', - ]); - $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); - $this->assertEquals([$vEventOld], $result['old']); - $this->assertEquals([$vEventNew], $result['new']); - } - - // Second test to certify fix for issue nextcloud/server#41084 - public function testSequenceNumberIncrementDetectedForFirstModificationToEventWithZeroInit(): void { - $vCalendarOld = new VCalendar(); - $vCalendarNew = new VCalendar(); - - $vEventOld = $vCalendarOld->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 0, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', - ]); - $vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventOld->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $vEventNew = $vCalendarNew->add('VEVENT', [ - 'UID' => 'uid-1234', - 'LAST-MODIFIED' => 123456, - 'SEQUENCE' => 1, - 'SUMMARY' => 'Fellowship meeting', - 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), - 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', - ]); - $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); - $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); - - $result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); - $this->assertEquals([$vEventOld], $result['old']); - $this->assertEquals([$vEventNew], $result['new']); - } - -} diff --git a/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginCharsetTest.php b/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginCharsetTest.php index 4763f675ddff7..1e69ef14af96a 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginCharsetTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginCharsetTest.php @@ -10,7 +10,6 @@ use OC\L10N\L10N; use OC\URLGenerator; -use OCA\DAV\CalDAV\EventComparisonService; use OCA\DAV\CalDAV\Schedule\IMipPlugin; use OCA\DAV\CalDAV\Schedule\IMipService; use OCP\Accounts\IAccountManager; @@ -60,7 +59,6 @@ class IMipPluginCharsetTest extends TestCase { private IUserManager&MockObject $userManager; // Services - private EventComparisonService $eventComparisonService; private IMipPlugin $imipPlugin; private IMipService $imipService; @@ -103,9 +101,6 @@ protected function setUp(): void { $this->appConfig, ); - // EventComparisonService - $this->eventComparisonService = new EventComparisonService(); - // IMipPlugin $message = new \OC\Mail\Message(new Email(), false); $this->mailer = $this->createMock(IMailer::class); @@ -130,7 +125,6 @@ protected function setUp(): void { $this->defaults, $this->userSession, $this->imipService, - $this->eventComparisonService, $this->mailManager, $this->getEmailValidatorWithStrictEmailCheck(), $this->createMock(IAccountManager::class), diff --git a/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php b/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php index b7cbcad149ef6..824fa5a364312 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php @@ -9,7 +9,6 @@ namespace OCA\DAV\Tests\unit\CalDAV\Schedule; -use OCA\DAV\CalDAV\EventComparisonService; use OCA\DAV\CalDAV\Schedule\IMipPlugin; use OCA\DAV\CalDAV\Schedule\IMipService; use OCP\Accounts\IAccount; @@ -59,7 +58,6 @@ class IMipPluginTest extends TestCase { private IMipService&MockObject $service; private Defaults&MockObject $defaults; private LoggerInterface&MockObject $logger; - private EventComparisonService&MockObject $eventComparisonService; private IMailManager&MockObject $mailManager; private IMailServiceMock&MockObject $mailService; private IMailMessageNew&MockObject $mailMessageNew; @@ -99,8 +97,6 @@ protected function setUp(): void { $this->service = $this->createMock(IMipService::class); - $this->eventComparisonService = $this->createMock(EventComparisonService::class); - $this->mailManager = $this->createMock(IMailManager::class); $this->mailService = $this->createMock(IMailServiceMock::class); @@ -117,7 +113,6 @@ protected function setUp(): void { $this->defaults, $this->userSession, $this->service, - $this->eventComparisonService, $this->mailManager, $this->getEmailValidatorWithStrictEmailCheck(), $this->accountManager, @@ -197,9 +192,9 @@ public function testParsingSingle(): void { ['dav', 'caldav_external_attendees_disabled', false, false], ['core', 'mail_providers_enabled', true, false], ]); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $newVevent, 'old' => $oldVEvent]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -307,9 +302,9 @@ public function testAttendeeIsResource(): void { ->method('getValueBool') ->with('dav', 'caldav_external_attendees_disabled', false) ->willReturn(false); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $newVevent, 'old' => $oldVEvent]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -385,9 +380,9 @@ public function testAttendeeIsCircle(): void { ->method('getValueBool') ->with('dav', 'caldav_external_attendees_disabled', false) ->willReturn(false); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['new' => [$newVevent], 'old' => null]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $newVevent, 'old' => null]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -492,9 +487,9 @@ public function testParsingRecurrence(): void { ['dav', 'caldav_external_attendees_disabled', false, false], ['core', 'mail_providers_enabled', true, false], ]); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['old' => [] ,'new' => [$newVevent]]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $newVevent, 'old' => null]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -555,6 +550,129 @@ public function testParsingRecurrence(): void { $this->assertEquals('1.1', $message->getScheduleStatus()); } + /** + * A message can bundle several changed instances (e.g. a recurring + * event's master plus one of its overrides edited together). The master + * is used to build the email regardless of its position in the list + * returned by findModifiedInstances(), and the fact that other instances + * were dropped from the email is logged. + */ + public function testMultipleModifiedInstancesUsesMasterAndLogsTheRest(): void { + $message = new Message(); + $message->method = 'REQUEST'; + $newVCalendar = new VCalendar(); + $masterVevent = new VEvent($newVCalendar, 'one', [ + 'UID' => 'uid-1234', + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $masterVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $masterVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + $overrideVevent = new VEvent($newVCalendar, 'two', [ + 'UID' => 'uid-1234', + 'SEQUENCE' => 1, + 'SUMMARY' => 'Elevenses', + 'DTSTART' => new \DateTime('2016-01-02 00:00:00'), + 'RECURRENCE-ID' => new \DateTime('2016-01-02 00:00:00'), + ]); + $overrideVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $overrideVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + $message->message = $newVCalendar; + $message->sender = 'mailto:gandalf@wiz.ard'; + $message->senderName = 'Mr. Wizard'; + $message->recipient = 'mailto:' . 'frodo@hobb.it'; + $data = ['invitee_name' => 'Mr. Wizard', + 'meeting_title' => 'Fellowship meeting', + 'attendee_name' => 'frodo@hobb.it' + ]; + $attendees = $masterVevent->select('ATTENDEE'); + $atnd = ''; + foreach ($attendees as $attendee) { + if (strcasecmp($attendee->getValue(), $message->recipient) === 0) { + $atnd = $attendee; + } + } + $this->service->expects(self::once()) + ->method('getLastOccurrence') + ->willReturn(1496912700); + $this->config->expects(self::exactly(2)) + ->method('getValueBool') + ->willReturnMap([ + ['dav', 'caldav_external_attendees_disabled', false, false], + ['core', 'mail_providers_enabled', true, false], + ]); + // the override is listed before the master on purpose: the master must still win + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([ + ['new' => $overrideVevent, 'old' => null], + ['new' => $masterVevent, 'old' => null], + ]); + $this->logger->expects(self::once()) + ->method('debug') + ->with(self::stringContains('multiple changed instances'), self::anything()); + $this->service->expects(self::once()) + ->method('getCurrentAttendee') + ->with($message) + ->willReturn($atnd); + $this->service->expects(self::once()) + ->method('isRoomOrResource') + ->with($atnd) + ->willReturn(false); + $this->service->expects(self::once()) + ->method('isCircle') + ->with($atnd) + ->willReturn(false); + $this->service->expects(self::once()) + ->method('buildBodyData') + ->with($masterVevent, null) + ->willReturn($data); + $this->user->expects(self::any()) + ->method('getUID') + ->willReturn('user1'); + $this->user->expects(self::any()) + ->method('getDisplayName') + ->willReturn('Mr. Wizard'); + $this->user->expects(self::any()) + ->method('getEMailAddress') + ->willReturn('gandalf@wiz.ard'); + $this->userSession->expects(self::any()) + ->method('getUser') + ->willReturn($this->user); + $this->service->expects(self::once()) + ->method('getFrom'); + $this->service->expects(self::once()) + ->method('addSubjectAndHeading') + ->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting', false); + $this->service->expects(self::once()) + ->method('addBulletList') + ->with($this->emailTemplate, $masterVevent, $data); + $this->service->expects(self::once()) + ->method('getAttendeeRsvpOrReqForParticipant') + ->willReturn(true); + $this->config->expects(self::once()) + ->method('getValueString') + ->with('dav', 'invitation_link_recipients', 'yes') + ->willReturn('yes'); + $this->service->expects(self::once()) + ->method('createInvitationToken') + ->with($message, $masterVevent, 1496912700) + ->willReturn('token'); + $this->service->expects(self::once()) + ->method('addResponseButtons') + ->with($this->emailTemplate, 'token'); + $this->service->expects(self::once()) + ->method('addMoreOptionsButton') + ->with($this->emailTemplate, 'token'); + $this->mailer->expects(self::once()) + ->method('send') + ->willReturn([]); + $this->plugin->schedule($message); + $this->assertEquals('1.1', $message->getScheduleStatus()); + } + public function testEmailValidationFailed(): void { $message = new Message(); $message->method = 'REQUEST'; @@ -622,9 +740,9 @@ public function testFailedDelivery(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['old' => [] ,'new' => [$newVevent]]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $newVevent, 'old' => null]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -781,9 +899,9 @@ public function testMailProviderSend(): void { $this->service->expects(self::once()) ->method('addMoreOptionsButton') ->with($this->emailTemplate, 'token'); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['old' => [] ,'new' => [$event]]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $event, 'old' => null]]); // construct mail provider mock returns $this->mailService ->method('initiateMessage') @@ -843,9 +961,9 @@ public function testMailProviderDisabled(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $newVevent, 'old' => $oldVEvent]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -948,10 +1066,10 @@ public function testNoOldEvent(): void { ['dav', 'caldav_external_attendees_disabled', false, false], ['core', 'mail_providers_enabled', true, false], ]); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') + $this->service->expects(self::once()) + ->method('findModifiedInstances') ->with($newVCalendar, null) - ->willReturn(['old' => [] ,'new' => [$newVevent]]); + ->willReturn([['new' => $newVevent, 'old' => null]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -1050,10 +1168,10 @@ public function testNoButtons(): void { ['dav', 'caldav_external_attendees_disabled', false, false], ['core', 'mail_providers_enabled', true, false], ]); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') + $this->service->expects(self::once()) + ->method('findModifiedInstances') ->with($newVCalendar, null) - ->willReturn(['old' => [] ,'new' => [$newVevent]]); + ->willReturn([['new' => $newVevent, 'old' => null]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -1141,8 +1259,8 @@ public function testExternalAttendeesDisabledForExternalUser(): void { ->method('isSystemUser') ->with('external@example.com') ->willReturn(false); - $this->eventComparisonService->expects(self::never()) - ->method('findModified'); + $this->service->expects(self::never()) + ->method('findModifiedInstances'); $this->service->expects(self::never()) ->method('getCurrentAttendee'); $this->mailer->expects(self::never()) @@ -1205,9 +1323,9 @@ public function testExternalAttendeesDisabledForSystemUser(): void { ->method('isSystemUser') ->with('frodo@hobb.it') ->willReturn(true); - $this->eventComparisonService->expects(self::once()) - ->method('findModified') - ->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); + $this->service->expects(self::once()) + ->method('findModifiedInstances') + ->willReturn([['new' => $newVevent, 'old' => $oldVEvent]]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -1346,8 +1464,8 @@ private function scheduleWithoutSenderName(string $organizer, string $recipient, ['dav', 'caldav_external_attendees_disabled', false, false], ['core', 'mail_providers_enabled', true, $viaMailProvider], ]); - $this->eventComparisonService->method('findModified') - ->willReturn(['old' => [], 'new' => [$vEvent]]); + $this->service->method('findModifiedInstances') + ->willReturn([['new' => $vEvent, 'old' => null]]); $plugin = new IMipPlugin( $this->config, @@ -1357,7 +1475,6 @@ private function scheduleWithoutSenderName(string $organizer, string $recipient, $this->defaults, $this->userSession, $this->service, - $this->eventComparisonService, $this->mailManager, $this->getEmailValidatorWithStrictEmailCheck(), $this->accountManager, diff --git a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php index ac3aa17873745..7f8680a245443 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php @@ -2572,4 +2572,242 @@ public function testAddBulletListEscapesNonHtmlProperties(): void { $this->assertSame(htmlspecialchars($data['meeting_occurring']), $actualHtmlValues[4]); $this->assertSame(htmlspecialchars($data['meeting_description']), $actualHtmlValues[5]); } + + public function testFindModifiedInstancesNoModifiedEvent(): void { + $vCalendarOld = new VCalendar(); + $vCalendarNew = new VCalendar(); + + $vEventOld = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventOld->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $vEventNew = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $result = $this->service->findModifiedInstances($vCalendarNew, $vCalendarOld); + $this->assertEmpty($result); + } + + public function testFindModifiedInstancesNewEvent(): void { + $vCalendarOld = null; + $vCalendarNew = new VCalendar(); + + $vEventNew = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $result = $this->service->findModifiedInstances($vCalendarNew, $vCalendarOld); + $this->assertEquals([['new' => $vEventNew, 'old' => null]], $result); + } + + public function testFindModifiedInstancesModifiedUnmodifiedEvent(): void { + $vCalendarOld = new VCalendar(); + $vCalendarNew = new VCalendar(); + + $vEventOld1 = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + ]); + $vEventOld1->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventOld1->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $vEventOld2 = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-1235', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + ]); + $vEventOld2->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventOld2->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $vEventNew1 = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + ]); + $vEventNew1->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventNew1->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $vEventNew2 = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1235', + 'LAST-MODIFIED' => 123457, + 'SEQUENCE' => 3, + 'SUMMARY' => 'Fellowship meeting 2', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + ]); + $vEventNew2->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventNew2->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $result = $this->service->findModifiedInstances($vCalendarNew, $vCalendarOld); + $this->assertEquals([['new' => $vEventNew2, 'old' => $vEventOld2]], $result); + } + + /** + * Instances are matched by UID (and RECURRENCE-ID), not just by their + * SEQUENCE/RRULE/LAST-MODIFIED hash, so two unrelated events that + * happen to share those values must never be paired with each other. + */ + public function testFindModifiedInstancesNeverPairsAcrossDifferentUids(): void { + $vCalendarOld = new VCalendar(); + $vCalendarNew = new VCalendar(); + + $vEventOld = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-old', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + ]); + $vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + + $vEventNew = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-new', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + ]); + $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + + $result = $this->service->findModifiedInstances($vCalendarNew, $vCalendarOld); + $this->assertEquals([['new' => $vEventNew, 'old' => null]], $result); + } + + /** + * A message bundling a recurring event's master together with one of + * its overrides must pair each instance with its own matching old + * counterpart, not with each other or by array position. + */ + public function testFindModifiedInstancesPairsMasterAndOverrideIndependently(): void { + $vCalendarOld = new VCalendar(); + $vCalendarNew = new VCalendar(); + + $recurrenceId = new \DateTime('2016-01-02 00:00:00'); + + $oldMaster = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-1234', + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $oldOverride = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-1234', + 'SEQUENCE' => 1, + 'SUMMARY' => 'Elevenses', + 'DTSTART' => $recurrenceId, + 'RECURRENCE-ID' => $recurrenceId, + ]); + + $newMaster = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1234', + 'SEQUENCE' => 3, + 'SUMMARY' => 'Fellowship meeting, moved', + 'DTSTART' => new \DateTime('2016-01-01 01:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $newOverride = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1234', + 'SEQUENCE' => 2, + 'SUMMARY' => 'Elevenses, moved', + 'DTSTART' => $recurrenceId, + 'RECURRENCE-ID' => $recurrenceId, + ]); + + $result = $this->service->findModifiedInstances($vCalendarNew, $vCalendarOld); + $this->assertEquals([ + ['new' => $newMaster, 'old' => $oldMaster], + ['new' => $newOverride, 'old' => $oldOverride], + ], $result); + } + + // First test to certify fix for issue nextcloud/server#41084 + public function testFindModifiedInstancesSequenceNumberIncrementDetectedForFirstModificationToEventWithoutZeroInit(): void { + $vCalendarOld = new VCalendar(); + $vCalendarNew = new VCalendar(); + + $vEventOld = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + // 'SEQUENCE' => 0, // sequence number may not be set to zero during event creation and instead fully omitted + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventOld->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $vEventNew = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 1, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $result = $this->service->findModifiedInstances($vCalendarNew, $vCalendarOld); + $this->assertEquals([['new' => $vEventNew, 'old' => $vEventOld]], $result); + } + + // Second test to certify fix for issue nextcloud/server#41084 + public function testFindModifiedInstancesSequenceNumberIncrementDetectedForFirstModificationToEventWithZeroInit(): void { + $vCalendarOld = new VCalendar(); + $vCalendarNew = new VCalendar(); + + $vEventOld = $vCalendarOld->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 0, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventOld->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $vEventNew = $vCalendarNew->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 1, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + 'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', + ]); + $vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + + $result = $this->service->findModifiedInstances($vCalendarNew, $vCalendarOld); + $this->assertEquals([['new' => $vEventNew, 'old' => $vEventOld]], $result); + } }