Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/dav/lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
use OCP\User\Events\UserIdUnassignedEvent;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Sabre\VObject;
use Throwable;
use function is_null;

Expand Down Expand Up @@ -236,6 +237,8 @@ public function register(IRegistrationContext $context): void {
}

public function boot(IBootContext $context): void {
VObject\Component\VCalendar::$propertyMap['X-NC-PARTY-CRASHER'] = VObject\Property\Boolean::class;

// Load all dav apps
$context->getServerContainer()->get(IAppManager::class)->loadApps(['dav']);

Expand Down
151 changes: 151 additions & 0 deletions apps/dav/lib/CalDAV/TipBroker.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@

use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\ITip\Broker;
use Sabre\VObject\ITip\Message;
use Sabre\VObject\Property\Boolean;
use Sabre\VObject\Recur\EventIterator;

class TipBroker extends Broker {

Expand Down Expand Up @@ -78,6 +81,154 @@
return $existingObject;
}

/**
* Processes incoming REPLY messages.
*
* The message is a reply. This is for example an attendee telling
* an organizer he accepted the invite, or declined it.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* an organizer he accepted the invite, or declined it.
* an organizer they accepted the invite, or declined it.

nit: neutral language

*
* @param VCalendar $existingObject
*
* @return VCalendar|null
*/
protected function processMessageReply(Message $itipMessage, ?VCalendar $existingObject = null) {
// A reply can only be processed based on an existing object.
// If the object is not available, the reply is ignored.
if (!$existingObject) {
return;
}
$instances = [];
$requestStatus = '2.0';

// Finding all the instances the attendee replied to.
foreach ($itipMessage->message->VEVENT as $vevent) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $vevent->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($vevent->{'REQUEST-STATUS'})) {
$requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
[$requestStatus] = explode(';', $requestStatus);
}
}

// Now we need to loop through the original organizer event, to find
// all the instances where we have a reply for.
$masterObject = null;
$allowPartyCrasher = true;
foreach ($existingObject->VEVENT as $vevent) {
if (!isset($vevent->{'RECURRENCE-ID'})) {
$masterObject = $vevent;
$allowPartyCrasher = $this->partyCrasher($vevent);
Comment on lines +102 to +124
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processMessageReply() iterates over $itipMessage->message->VEVENT and $existingObject->VEVENT, ignoring $itipMessage->component. TipBroker elsewhere supports different component types (e.g. VTODO/VJOURNAL), so this override will fail or silently ignore replies for non-VEVENT components. Consider using $componentType = $itipMessage->component and iterating $itipMessage->message->$componentType / $existingObject->$componentType (and adjust partyCrasher() accordingly) to keep behavior consistent.

Suggested change
// Finding all the instances the attendee replied to.
foreach ($itipMessage->message->VEVENT as $vevent) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $vevent->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($vevent->{'REQUEST-STATUS'})) {
$requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
[$requestStatus] = explode(';', $requestStatus);
}
}
// Now we need to loop through the original organizer event, to find
// all the instances where we have a reply for.
$masterObject = null;
$allowPartyCrasher = true;
foreach ($existingObject->VEVENT as $vevent) {
if (!isset($vevent->{'RECURRENCE-ID'})) {
$masterObject = $vevent;
$allowPartyCrasher = $this->partyCrasher($vevent);
$componentType = $itipMessage->component;
// Finding all the instances the attendee replied to.
foreach (isset($itipMessage->message->{$componentType}) ? $itipMessage->message->{$componentType} : [] as $component) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($component->{'RECURRENCE-ID'}) ? $component->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $component->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($component->{'REQUEST-STATUS'})) {
$requestStatus = $component->{'REQUEST-STATUS'}->getValue();
[$requestStatus] = explode(';', $requestStatus);
}
}
// Now we need to loop through the original organizer component, to find
// all the instances where we have a reply for.
$masterObject = null;
$allowPartyCrasher = true;
foreach (isset($existingObject->{$componentType}) ? $existingObject->{$componentType} : [] as $component) {
if (!isset($component->{'RECURRENCE-ID'})) {
$masterObject = $component;
if ($component instanceof VEvent) {
$allowPartyCrasher = $this->partyCrasher($component);
}

Copilot uses AI. Check for mistakes.
break;
}
}

foreach ($existingObject->VEVENT as $vevent) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
if (isset($instances[$recurId])) {
$attendeeFound = false;
if (isset($vevent->ATTENDEE)) {
foreach ($vevent->ATTENDEE as $attendee) {
if ($attendee->getValue() === $itipMessage->sender) {
$attendeeFound = true;
$attendee['PARTSTAT'] = $instances[$recurId];
$attendee['SCHEDULE-STATUS'] = $requestStatus;
// Un-setting the RSVP status, because we now know
// that the attendee already replied.
unset($attendee['RSVP']);
break;
}
}
}
if (!$attendeeFound && $allowPartyCrasher) {
// Adding a new attendee. The iTip documentation calls this
// a party crasher.
$attendee = $vevent->add('ATTENDEE', $itipMessage->sender, [
'PARTSTAT' => $instances[$recurId],
]);
if ($itipMessage->senderName) {
$attendee['CN'] = $itipMessage->senderName;
}
}
unset($instances[$recurId]);
}
}

if (!$masterObject) {
// No master object, we can't add new instances.
return;
}
// If we got replies to instances that did not exist in the
// original list, it means that new exceptions must be created.
foreach ($instances as $recurId => $partstat) {
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
$found = false;
$iterations = 1000;
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();

// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);

Comment on lines +168 to +183
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processMessageReply() constructs new EventIterator($existingObject, $itipMessage->uid) without handling exceptions. EventIterator can throw (e.g. NoInstancesException is handled elsewhere in the codebase), so a malformed or unexpected recurring definition could bubble up as an unhandled exception and break scheduling processing. Wrap the iterator creation/iteration in a try/catch and treat failures as an invalid recurrence (ignore the reply / skip instance) instead of hard-failing.

Suggested change
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
$found = false;
$iterations = 1000;
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();
// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);
$found = false;
$iterations = 1000;
$newObject = null;
try {
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();
// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);
} catch (\Throwable $e) {
// Invalid or non-expandable recurrence. Skip this reply instance.
continue;
}

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +183
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop that tries to match missing recurrences re-creates an EventIterator for each $instances entry and then scans up to 1000 iterations from the start. If a reply contains many RECURRENCE-ID values, this becomes expensive (O(n * 1000)) and can be abused to cause high CPU usage. Consider enforcing a reasonable cap on the number of instances processed from a single reply and/or reusing a single iterator (or fast-forwarding to the target) to avoid repeated full scans.

Copilot uses AI. Check for mistakes.
// Invalid recurrence id. Skipping this object.
if (!$found) {
continue;
}

unset(
$newObject->RRULE,
$newObject->EXDATE,
$newObject->RDATE
);
$attendeeFound = false;
if (isset($newObject->ATTENDEE)) {
foreach ($newObject->ATTENDEE as $attendee) {
if ($attendee->getValue() === $itipMessage->sender) {
$attendeeFound = true;
$attendee['PARTSTAT'] = $partstat;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is matching the upstream: https://github.com/sabre-io/vobject/blob/2104a3ea37e248262617a8acbfe7648d8e2fd8bd/lib/ITip/Broker.php#L437

@SebastianKrupinski do you know why we don't unset RSVP here like for the existing events in the loop before?

break;
}
Comment on lines +194 to +201
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For generated exceptions ($newObject), when the attendee already exists you only update PARTSTAT, but for existing components earlier in the method you also set SCHEDULE-STATUS and unset RSVP. This inconsistency means generated instances may keep stale RSVP flags / miss schedule status tracking. Consider applying the same updates (SCHEDULE-STATUS, unset(RSVP), etc.) in the generated-instance path as well.

Copilot uses AI. Check for mistakes.
}
}
if (!$attendeeFound && !$allowPartyCrasher) {
continue;
}
if (!$attendeeFound) {
// Adding a new attendee
$attendee = $newObject->add('ATTENDEE', $itipMessage->sender, [
'PARTSTAT' => $partstat,
]);
if ($itipMessage->senderName) {
$attendee['CN'] = $itipMessage->senderName;

Check failure on line 213 in apps/dav/lib/CalDAV/TipBroker.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidArgument

apps/dav/lib/CalDAV/TipBroker.php:213:16: InvalidArgument: Argument 1 of Sabre\VObject\Node::offsetGet expects int, but 'CN' provided (see https://psalm.dev/004)

Check failure on line 213 in apps/dav/lib/CalDAV/TipBroker.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidArgument

apps/dav/lib/CalDAV/TipBroker.php:213:16: InvalidArgument: Argument 1 of Sabre\VObject\Node::offsetSet expects int, but 'CN' provided (see https://psalm.dev/004)
}
}
$existingObject->add($newObject);
}

return $existingObject;
}

protected function partyCrasher(VEvent $vevent): bool {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: naming. Could this be allowPartyCrashers?

$properties = $vevent->select('X-NC-PARTY-CRASHER');
foreach ($properties as $property) {
if ($property instanceof Boolean) {
return $property->getValue() === 'TRUE';
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

partyCrasher() only checks properties that are instances of Sabre\VObject\Property\Boolean. If the event contains X-NC-PARTY-CRASHER:FALSE but it gets parsed as a generic/unknown property (e.g. if the property map wasn’t initialized before parsing), this method will fall back to true and still allow party crashers. To make this robust, consider also checking the property's string value (case-insensitive) whenever the property exists, not only when it’s a Boolean instance.

Suggested change
}
}
$value = strtoupper(trim((string)$property->getValue()));
if ($value === 'TRUE' || $value === '1' || $value === 'YES') {
return true;
}
if ($value === 'FALSE' || $value === '0' || $value === 'NO') {
return false;
}

Copilot uses AI. Check for mistakes.
}
return true;
}

/**
* 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
Expand Down
164 changes: 164 additions & 0 deletions apps/dav/tests/unit/CalDAV/TipBrokerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
namespace OCA\DAV\Tests\unit\CalDAV;

use OCA\DAV\CalDAV\TipBroker;
use Sabre\VObject;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\ITip\Message;
use Test\TestCase;

class TipBrokerTest extends TestCase {
Expand All @@ -21,6 +23,8 @@ class TipBrokerTest extends TestCase {
protected function setUp(): void {
parent::setUp();

VCalendar::$propertyMap['X-NC-PARTY-CRASHER'] = VObject\Property\Boolean::class;

Comment on lines +26 to +27
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setUp() mutates the global static VCalendar::$propertyMap but doesn’t restore it in tearDown(). Because this is shared global state, it can leak into other tests running in the same process and cause order-dependent behavior. Consider capturing the previous value and restoring it in tearDown() (or using a local helper) to keep the test isolated.

Copilot uses AI. Check for mistakes.
$this->broker = new TipBroker();

$this->templateEventInfo = [
Expand Down Expand Up @@ -579,4 +583,164 @@ public function testParseEventForOrganizerScheduleForceSend(): void {
$this->assertFalse(isset($messages[0]->message->VEVENT->ATTENDEE['SCHEDULE-FORCE-SEND']));
}

public function testProcessMessageReplyDisallowsPartyCrasher(): void {
$existingCalendar = clone $this->vCalendar1a;
$existingCalendar->VEVENT->add('X-NC-PARTY-CRASHER', 'FALSE');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@testing.com';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);

$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);

$this->assertSame($existingCalendar, $result);
$this->assertCount(1, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee1@testing.com', $result->VEVENT->ATTENDEE[0]->getValue());
}

public function testProcessMessageReplyAllowsPartyCrasher(): void {
$existingCalendar = clone $this->vCalendar1a;
$existingCalendar->VEVENT->add('X-NC-PARTY-CRASHER', 'TRUE');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@testing.com';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);

$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);

$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee2@testing.com', $result->VEVENT->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT->ATTENDEE[1]['CN']->getValue());
}

public function testProcessMessageReplyAllowsPartyCrasherByDefault(): void {
$existingCalendar = clone $this->vCalendar1a;
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@testing.com';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);

$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);

$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee2@testing.com', $result->VEVENT->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT->ATTENDEE[1]['CN']->getValue());
}

public function testProcessMessageReplyDisallowsPartyCrasherForGeneratedRecurringInstance(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->add('X-NC-PARTY-CRASHER', 'FALSE');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@testing.com';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);

$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);

$this->assertSame($existingCalendar, $result);
$this->assertCount(1, $result->VEVENT);
$this->assertCount(1, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee1@testing.com', $result->VEVENT->ATTENDEE[0]->getValue());
}

public function testProcessMessageReplyAllowsPartyCrasherForGeneratedRecurringInstance(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->add('X-NC-PARTY-CRASHER', 'TRUE');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@testing.com';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);

$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);

$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT);
$this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue());
$this->assertFalse(isset($result->VEVENT[1]->RRULE));
$this->assertCount(2, $result->VEVENT[1]->ATTENDEE);
$this->assertEquals('mailto:attendee2@testing.com', $result->VEVENT[1]->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT[1]->ATTENDEE[1]['CN']->getValue());
}

public function testProcessMessageReplyAllowsPartyCrasherByDefaultForGeneratedRecurringInstance(): void {
$existingCalendar = clone $this->vCalendar2a;
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@testing.com';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);

$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);

$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT);
$this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue());
$this->assertFalse(isset($result->VEVENT[1]->RRULE));
$this->assertCount(2, $result->VEVENT[1]->ATTENDEE);
$this->assertEquals('mailto:attendee2@testing.com', $result->VEVENT[1]->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT[1]->ATTENDEE[1]['CN']->getValue());
}

}
Loading