From 678668da8d18cace1c0ca7b4e3a98b8eeb43fd8f Mon Sep 17 00:00:00 2001 From: lazerg Date: Sat, 14 Nov 2026 00:34:23 +0500 Subject: [PATCH 1/2] fix(dav): mark declined room booking as declined in the room calendar Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: lazerg --- apps/dav/lib/CalDAV/Schedule/Plugin.php | 46 ++++++++++++++ .../tests/unit/CalDAV/Schedule/PluginTest.php | 60 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index 3cc0d190d3b93..3c3da5a284e98 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -338,6 +338,7 @@ public function scheduleLocalDelivery(ITip\Message $iTipMessage):void { $partStat = 'ACCEPTED'; } else { $partStat = 'DECLINED'; + $this->declineDeliveredObject($principalUri, $iTipMessage->recipient, $uid, $recurrenceId); } $vObject = Reader::read(vsprintf($message, [ @@ -506,6 +507,51 @@ protected function getCalendarUserTypeForPrincipal($principal):?string { return $properties[$calendarUserType]; } + /** + * Marks the delivered copy as declined, so it doesn't block the slot + * + * @param string $principalUri + * @param string $recipient + * @param string $uid + * @param string $recurrenceId + * @return void + */ + private function declineDeliveredObject(string $principalUri, string $recipient, string $uid, string $recurrenceId):void { + /** @var \OCA\DAV\CalDAV\Plugin $caldavPlugin */ + $caldavPlugin = $this->server->getPlugin('caldav'); + $homePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUri); + if (!$homePath) { + return; + } + + /** @var CalendarHome $calendarHome */ + $calendarHome = $this->server->tree->getNodeForPath($homePath); + $objectPath = $calendarHome->getCalendarObjectByUID($uid); + if (!$objectPath) { + return; + } + + /** @var CalendarObject $calendarObject */ + $calendarObject = $this->server->tree->getNodeForPath($homePath . '/' . $objectPath); + /** @var VCalendar $vCalendar */ + $vCalendar = Reader::read($calendarObject->get()); + foreach ($vCalendar->VEVENT as $vevent) { + $eventRecurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->serialize() : ''; + if ($eventRecurrenceId !== $recurrenceId) { + continue; + } + + foreach ($vevent->select('ATTENDEE') as $attendee) { + if (strcasecmp($attendee->getValue(), $recipient) === 0) { + $attendee['PARTSTAT'] = 'DECLINED'; + } + } + $vevent->TRANSP = 'TRANSPARENT'; + } + + $calendarObject->put($vCalendar->serialize()); + } + /** * @param ITip\Message $iTipMessage * @return null|Property diff --git a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php index 6f765ab21c817..a93f64aab1d8e 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php @@ -11,6 +11,7 @@ use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CalDAV\CalendarHome; +use OCA\DAV\CalDAV\CalendarObject; use OCA\DAV\CalDAV\DefaultCalendarValidator; use OCA\DAV\CalDAV\Plugin as CalDAVPlugin; use OCA\DAV\CalDAV\Schedule\Plugin; @@ -33,6 +34,7 @@ use Sabre\VObject\ITip\Message; use Sabre\VObject\Parameter; use Sabre\VObject\Property\ICalendar\CalAddress; +use Sabre\VObject\Reader; use Sabre\Xml\Service; use Test\TestCase; @@ -768,4 +770,62 @@ public function testCalendarObjectChangeWithSchedulingDisabled(): void { $newFlag ); } + + public function testDeclineDeliveredObject(): void { + $calendarData = <<createMock(CalDAVPlugin::class); + $calDAVPlugin->expects($this->once()) + ->method('getCalendarHomeForPrincipal') + ->with('principals/calendar-rooms/room1') + ->willReturn('calendars/calendar-rooms/room1'); + $this->server->expects($this->once()) + ->method('getPlugin') + ->with('caldav') + ->willReturn($calDAVPlugin); + + $calendarHome = $this->createMock(CalendarHome::class); + $calendarHome->expects($this->once()) + ->method('getCalendarObjectByUID') + ->with('room-booking') + ->willReturn('calendar/room-booking.ics'); + + $storedData = null; + $calendarObject = $this->createMock(CalendarObject::class); + $calendarObject->method('get') + ->willReturn($calendarData); + $calendarObject->expects($this->once()) + ->method('put') + ->willReturnCallback(function (string $data) use (&$storedData): void { + $storedData = $data; + }); + + $tree = $this->createMock(Tree::class); + $tree->method('getNodeForPath') + ->willReturnMap([ + ['calendars/calendar-rooms/room1', $calendarHome], + ['calendars/calendar-rooms/room1/calendar/room-booking.ics', $calendarObject], + ]); + $this->server->tree = $tree; + + $this->invokePrivate($this->plugin, 'declineDeliveredObject', ['principals/calendar-rooms/room1', 'mailto:room1@testing.local', 'room-booking', '']); + + $vEvent = Reader::read($storedData)->VEVENT; + $this->assertEquals('TRANSPARENT', $vEvent->TRANSP->getValue()); + $this->assertEquals('ACCEPTED', $vEvent->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertEquals('DECLINED', $vEvent->ATTENDEE[1]['PARTSTAT']->getValue()); + } } From cc2d4a90d2e2288f8fb99e7becd4117fc074a2c9 Mon Sep 17 00:00:00 2001 From: lazerg Date: Sat, 14 Nov 2026 00:38:53 +0500 Subject: [PATCH 2/2] test(dav): cover declined room booking through scheduleLocalDelivery Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: lazerg --- .../tests/unit/CalDAV/Schedule/PluginTest.php | 144 ++++++++++++------ 1 file changed, 99 insertions(+), 45 deletions(-) diff --git a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php index a93f64aab1d8e..622a5ea014c90 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php @@ -21,12 +21,14 @@ use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Sabre\CalDAV\Backend\BackendInterface; +use Sabre\CalDAV\Schedule\Inbox; use Sabre\DAV\PropFind; use Sabre\DAV\Server; use Sabre\DAV\Tree; use Sabre\DAV\Xml\Property\Href; use Sabre\DAV\Xml\Property\LocalHref; use Sabre\DAVACL\IPrincipal; +use Sabre\DAVACL\Plugin as ACLPlugin; use Sabre\HTTP\Request; use Sabre\HTTP\Response; use Sabre\HTTP\ResponseInterface; @@ -771,61 +773,113 @@ public function testCalendarObjectChangeWithSchedulingDisabled(): void { ); } - public function testDeclineDeliveredObject(): void { - $calendarData = <<add('VEVENT', []); + $bookedEvent->UID->setValue('booked-event'); + $bookedEvent->add('DTSTART', '20261201T100000Z'); + $bookedEvent->add('DTEND', '20261201T110000Z'); + // construct the request for the same time slot + $vCalendar = new VCalendar(); + $vCalendar->add('METHOD', 'REQUEST'); + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('new-event'); + $vEvent->add('DTSTART', '20261201T100000Z'); + $vEvent->add('DTEND', '20261201T110000Z'); + $vEvent->add('ORGANIZER', 'mailto:user1@testing.local'); + $vEvent->add('ATTENDEE', 'mailto:room1@testing.local', [ + 'CUTYPE' => 'ROOM', + 'PARTSTAT' => 'NEEDS-ACTION', + 'RSVP' => 'TRUE' + ]); + $iTipMessage = new Message(); + $iTipMessage->uid = 'new-event'; + $iTipMessage->component = 'VEVENT'; + $iTipMessage->method = 'REQUEST'; + $iTipMessage->sender = 'mailto:user1@testing.local'; + $iTipMessage->recipient = 'mailto:room1@testing.local'; + $iTipMessage->message = $vCalendar; + // construct server plugins + $aclPlugin = $this->createMock(ACLPlugin::class); + $aclPlugin->method('getPrincipalByUri') + ->willReturn('principals/calendar-rooms/room1'); + $aclPlugin->method('checkPrivileges') + ->willReturn(true); + $aclPlugin->method('principalSearch') + ->willReturn([[200 => [ + $caldavNS . 'calendar-home-set' => new Href('calendars/room1'), + $caldavNS . 'schedule-inbox-URL' => new Href('calendars/room1/inbox'), + ]]]); $calDAVPlugin = $this->createMock(CalDAVPlugin::class); - $calDAVPlugin->expects($this->once()) - ->method('getCalendarHomeForPrincipal') - ->with('principals/calendar-rooms/room1') - ->willReturn('calendars/calendar-rooms/room1'); - $this->server->expects($this->once()) - ->method('getPlugin') - ->with('caldav') - ->willReturn($calDAVPlugin); - - $calendarHome = $this->createMock(CalendarHome::class); - $calendarHome->expects($this->once()) - ->method('getCalendarObjectByUID') - ->with('room-booking') - ->willReturn('calendar/room-booking.ics'); - + $calDAVPlugin->method('getCalendarHomeForPrincipal') + ->willReturn('calendars/room1'); + $this->server->method('getPlugin') + ->willReturnMap([ + ['acl', $aclPlugin], + ['caldav', $calDAVPlugin], + ]); + $this->server->method('getProperties') + ->willReturnCallback(function (string $path, array $propertyNames) use ($caldavNS) { + if ($propertyNames === [Plugin::CALENDAR_USER_TYPE]) { + return [Plugin::CALENDAR_USER_TYPE => 'ROOM']; + } + if ($path === 'principals/calendar-rooms/room1') { + return [ + $caldavNS . 'calendar-home-set' => new Href('calendars/room1'), + $caldavNS . 'schedule-inbox-URL' => new Href('calendars/room1/inbox'), + $caldavNS . 'schedule-default-calendar-URL' => new Href('calendars/room1/calendar'), + ]; + } + return []; + }); + // construct server tree $storedData = null; - $calendarObject = $this->createMock(CalendarObject::class); - $calendarObject->method('get') - ->willReturn($calendarData); - $calendarObject->expects($this->once()) + $bookedObject = $this->createMock(CalendarObject::class); + $bookedObject->method('get') + ->willReturn($bookedCalendar->serialize()); + $calendarNode = $this->createMock(Calendar::class); + $calendarNode->method('calendarQuery') + ->willReturn(['booked-event.ics']); + $calendarNode->method('getChild') + ->with('booked-event.ics') + ->willReturn($bookedObject); + $calendarNode->expects($this->once()) + ->method('createFile') + ->willReturnCallback(function (string $name, string $data) use (&$storedData): void { + $storedData = $data; + }); + $calendarHome = $this->createMock(CalendarHome::class); + $calendarHome->method('getCalendarObjectByUID') + ->willReturnOnConsecutiveCalls(null, 'calendar/new-event.ics'); + $calendarHome->method('getChildren') + ->willReturn([$calendarNode]); + $deliveredObject = $this->createMock(CalendarObject::class); + $deliveredObject->method('get') + ->willReturnCallback(function () use (&$storedData) { + return $storedData; + }); + $deliveredObject->expects($this->once()) ->method('put') ->willReturnCallback(function (string $data) use (&$storedData): void { $storedData = $data; }); - $tree = $this->createMock(Tree::class); $tree->method('getNodeForPath') ->willReturnMap([ - ['calendars/calendar-rooms/room1', $calendarHome], - ['calendars/calendar-rooms/room1/calendar/room-booking.ics', $calendarObject], + ['calendars/room1', $calendarHome], + ['calendars/room1/inbox', $this->createMock(Inbox::class)], + ['calendars/room1/calendar', $calendarNode], + ['calendars/room1/calendar/new-event.ics', $deliveredObject], ]); $this->server->tree = $tree; - - $this->invokePrivate($this->plugin, 'declineDeliveredObject', ['principals/calendar-rooms/room1', 'mailto:room1@testing.local', 'room-booking', '']); - - $vEvent = Reader::read($storedData)->VEVENT; - $this->assertEquals('TRANSPARENT', $vEvent->TRANSP->getValue()); - $this->assertEquals('ACCEPTED', $vEvent->ATTENDEE[0]['PARTSTAT']->getValue()); - $this->assertEquals('DECLINED', $vEvent->ATTENDEE[1]['PARTSTAT']->getValue()); + // execute method + $this->plugin->scheduleLocalDelivery($iTipMessage); + // test that the copy in the room calendar no longer blocks the slot + $this->assertEquals('1.2;Message delivered locally', $iTipMessage->scheduleStatus); + $storedEvent = Reader::read($storedData)->VEVENT; + $this->assertEquals('DECLINED', $storedEvent->ATTENDEE['PARTSTAT']->getValue()); + $this->assertEquals('TRANSPARENT', $storedEvent->TRANSP->getValue()); } }