Jelajahi Sumber

feat(user status): automate user status for events

and automatically set a user status to free or busy depending on their calendar
transparency, event status and availability settings

Signed-off-by: Anna Larch <anna@nextcloud.com>
Anna Larch 8 bulan lalu
induk
melakukan
f14a4f8fd7

+ 3 - 0
apps/dav/composer/composer/autoload_classmap.php

@@ -55,6 +55,7 @@ return array(
     'OCA\\DAV\\CalDAV\\CalendarProvider' => $baseDir . '/../lib/CalDAV/CalendarProvider.php',
     'OCA\\DAV\\CalDAV\\CalendarRoot' => $baseDir . '/../lib/CalDAV/CalendarRoot.php',
     'OCA\\DAV\\CalDAV\\EventComparisonService' => $baseDir . '/../lib/CalDAV/EventComparisonService.php',
+    'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
     'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
     'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
     'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php',
@@ -97,6 +98,8 @@ return array(
     'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
     'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
     'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => $baseDir . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
+    'OCA\\DAV\\CalDAV\\Status\\Status' => $baseDir . '/../lib/CalDAV/Status/Status.php',
+    'OCA\\DAV\\CalDAV\\Status\\StatusService' => $baseDir . '/../lib/CalDAV/Status/StatusService.php',
     'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
     'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
     'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => $baseDir . '/../lib/CalDAV/Trashbin/Plugin.php',

+ 3 - 0
apps/dav/composer/composer/autoload_static.php

@@ -70,6 +70,7 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\CalDAV\\CalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarProvider.php',
         'OCA\\DAV\\CalDAV\\CalendarRoot' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarRoot.php',
         'OCA\\DAV\\CalDAV\\EventComparisonService' => __DIR__ . '/..' . '/../lib/CalDAV/EventComparisonService.php',
+        'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
         'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
         'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
         'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php',
@@ -112,6 +113,8 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
         'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
         'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
+        'OCA\\DAV\\CalDAV\\Status\\Status' => __DIR__ . '/..' . '/../lib/CalDAV/Status/Status.php',
+        'OCA\\DAV\\CalDAV\\Status\\StatusService' => __DIR__ . '/..' . '/../lib/CalDAV/Status/StatusService.php',
         'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
         'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
         'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/Plugin.php',

+ 27 - 0
apps/dav/lib/CalDAV/CalendarImpl.php

@@ -33,11 +33,15 @@ use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
 use OCP\Calendar\Exceptions\CalendarException;
 use OCP\Calendar\ICreateFromString;
 use OCP\Calendar\IHandleImipMessage;
+use OCP\Calendar\ISchedulingInformation;
 use OCP\Constants;
+use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
 use Sabre\DAV\Exception\Conflict;
 use Sabre\VObject\Component\VCalendar;
 use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\Component\VTimeZone;
 use Sabre\VObject\ITip\Message;
+use Sabre\VObject\Property;
 use Sabre\VObject\Reader;
 use function Sabre\Uri\split as uriSplit;
 
@@ -86,6 +90,29 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage {
 		return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color'];
 	}
 
+	public function getSchedulingTransparency(): ?ScheduleCalendarTransp {
+		return $this->calendarInfo['{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}schedule-calendar-transp'];
+	}
+
+	public function getSchedulingTimezone(): ?VTimeZone {
+		$tzProp = '{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}calendar-timezone';
+		if (!isset($this->calendarInfo[$tzProp])) {
+			return null;
+		}
+		// This property contains a VCALENDAR with a single VTIMEZONE
+		/** @var string $timezoneProp */
+		$timezoneProp = $this->calendarInfo[$tzProp];
+		/** @var VCalendar $vobj */
+		$vobj = Reader::read($timezoneProp);
+		$components = $vobj->getComponents();
+		if(empty($components)) {
+			return null;
+		}
+		/** @var VTimeZone $vtimezone */
+		$vtimezone = $components[0];
+		return $vtimezone;
+	}
+
 	/**
 	 * @param string $pattern which should match within the $searchProperties
 	 * @param array $searchProperties defines the properties within the query pattern should match

+ 44 - 0
apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php

@@ -0,0 +1,44 @@
+<?php
+declare(strict_types=1);
+/*
+ * *
+ *  *
+ *  * @copyright 2023 Anna Larch <anna.larch@gmx.net>
+ *  *
+ *  * @author Anna Larch <anna.larch@gmx.net>
+ *  *
+ *  * This library is free software; you can redistribute it and/or
+ *  * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ *  * License as published by the Free Software Foundation; either
+ *  * version 3 of the License, or any later version.
+ *  *
+ *  * This library is distributed in the hope that it will be useful,
+ *  * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *  *
+ *  * You should have received a copy of the GNU Affero General Public
+ *  * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
+ *  *
+ *
+ */
+
+namespace OCA\DAV\CalDAV\FreeBusy;
+
+use DateTimeInterface;
+use DateTimeZone;
+use Sabre\VObject\Component\VCalendar;
+
+/**
+ * @psalm-suppress PropertyNotSetInConstructor
+ */
+class FreeBusyGenerator extends \Sabre\VObject\FreeBusyGenerator {
+
+	public function __construct() {
+		parent::__construct();
+	}
+
+	public function getVCalendar(): VCalendar {
+		return new VCalendar();
+	}
+}

+ 2 - 0
apps/dav/lib/CalDAV/Schedule/Plugin.php

@@ -36,6 +36,7 @@ use OCA\DAV\CalDAV\CalendarHome;
 use OCP\IConfig;
 use Psr\Log\LoggerInterface;
 use Sabre\CalDAV\ICalendar;
+use Sabre\CalDAV\Schedule\IOutbox;
 use Sabre\DAV\INode;
 use Sabre\DAV\IProperties;
 use Sabre\DAV\PropFind;
@@ -44,6 +45,7 @@ use Sabre\DAV\Xml\Property\LocalHref;
 use Sabre\DAVACL\IPrincipal;
 use Sabre\HTTP\RequestInterface;
 use Sabre\HTTP\ResponseInterface;
+use Sabre\VObject;
 use Sabre\VObject\Component;
 use Sabre\VObject\Component\VCalendar;
 use Sabre\VObject\Component\VEvent;

+ 57 - 0
apps/dav/lib/CalDAV/Status/Status.php

@@ -0,0 +1,57 @@
+<?php
+/*
+ * *
+ *  * Dav App
+ *  *
+ *  * @copyright 2023 Anna Larch <anna.larch@gmx.net>
+ *  *
+ *  * @author Anna Larch <anna.larch@gmx.net>
+ *  *
+ *  * This library is free software; you can redistribute it and/or
+ *  * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ *  * License as published by the Free Software Foundation; either
+ *  * version 3 of the License, or any later version.
+ *  *
+ *  * This library is distributed in the hope that it will be useful,
+ *  * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *  *
+ *  * You should have received a copy of the GNU Affero General Public
+ *  * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
+ *  *
+ *
+ */
+
+namespace OCA\DAV\CalDAV\Status;
+
+class Status {
+
+	public function __construct(private string $status = '', private ?string $message = null, private ?string $customMessage = null){}
+
+	public function getStatus(): string {
+		return $this->status;
+	}
+
+	public function setStatus(string $status): void {
+		$this->status = $status;
+	}
+
+	public function getMessage(): ?string {
+		return $this->message;
+	}
+
+	public function setMessage(?string $message): void {
+		$this->message = $message;
+	}
+
+	public function getCustomMessage(): ?string {
+		return $this->customMessage;
+	}
+
+	public function setCustomMessage(?string $customMessage): void {
+		$this->customMessage = $customMessage;
+	}
+
+
+}

+ 236 - 0
apps/dav/lib/CalDAV/Status/StatusService.php

@@ -0,0 +1,236 @@
+<?php
+/*
+ * *
+ *  * Dav App
+ *  *
+ *  * @copyright 2023 Anna Larch <anna.larch@gmx.net>
+ *  *
+ *  * @author Anna Larch <anna.larch@gmx.net>
+ *  *
+ *  * This library is free software; you can redistribute it and/or
+ *  * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ *  * License as published by the Free Software Foundation; either
+ *  * version 3 of the License, or any later version.
+ *  *
+ *  * This library is distributed in the hope that it will be useful,
+ *  * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *  *
+ *  * You should have received a copy of the GNU Affero General Public
+ *  * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
+ *  *
+ *
+ */
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2023 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+namespace OCA\DAV\CalDAV\Status;
+
+use DateTimeZone;
+use OC\Calendar\CalendarQuery;
+use OCA\DAV\CalDAV\CalendarImpl;
+use OCA\DAV\CalDAV\FreeBusy\FreeBusyGenerator;
+use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
+use OCA\DAV\CalDAV\IUser;
+use OCA\DAV\CalDAV\Schedule\Plugin as SchedulePlugin;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\Calendar\IManager;
+use OCP\Calendar\ISchedulingInformation;
+use OCP\IL10N;
+use OCP\IUser as User;
+use OCP\UserStatus\IUserStatus;
+use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
+use Sabre\DAV\Exception\NotAuthenticated;
+use Sabre\DAVACL\Exception\NeedPrivileges;
+use Sabre\DAVACL\Plugin as AclPlugin;
+use Sabre\VObject\Component;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\Parameter;
+use Sabre\VObject\Property;
+use Sabre\VObject\Reader;
+
+class StatusService {
+	public function __construct(private ITimeFactory $timeFactory,
+								private IManager $calendarManager,
+								private InvitationResponseServer $server,
+								private IL10N $l10n,
+								private FreeBusyGenerator $generator){}
+
+	public function processCalendarAvailability(User $user, ?string $availability): ?Status {
+		$userId = $user->getUID();
+		$email = $user->getEMailAddress();
+		if($email === null) {
+			return null;
+		}
+
+		$server = $this->server->getServer();
+
+		/** @var SchedulePlugin $schedulingPlugin */
+		$schedulingPlugin = $server->getPlugin('caldav-schedule');
+		$caldavNS = '{'.$schedulingPlugin::NS_CALDAV.'}';
+
+		/** @var AclPlugin $aclPlugin */
+		$aclPlugin = $server->getPlugin('acl');
+		if ('mailto:' === substr($email, 0, 7)) {
+			$email = substr($email, 7);
+		}
+
+		$result = $aclPlugin->principalSearch(
+			['{http://sabredav.org/ns}email-address' => $email],
+			[
+				'{DAV:}principal-URL',
+				$caldavNS.'calendar-home-set',
+				$caldavNS.'schedule-inbox-URL',
+				'{http://sabredav.org/ns}email-address',
+			]
+		);
+
+		if (!count($result) || !isset($result[0][200][$caldavNS.'schedule-inbox-URL'])) {
+			return null;
+		}
+
+		$inboxUrl = $result[0][200][$caldavNS.'schedule-inbox-URL']->getHref();
+
+		// Do we have permission?
+		try {
+			$aclPlugin->checkPrivileges($inboxUrl, $caldavNS.'schedule-query-freebusy');
+		} catch (NeedPrivileges | NotAuthenticated $exception) {
+			return null;
+		}
+
+		$now = $this->timeFactory->now();
+		$calendarTimeZone = $now->getTimezone();
+		$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId);
+		if(empty($calendars)) {
+			return null;
+		}
+
+		$query = $this->calendarManager->newQuery('principals/users/' . $userId);
+		foreach ($calendars as $calendarObject) {
+			// We can only work with a calendar if it exposes its scheduling information
+			if (!$calendarObject instanceof CalendarImpl) {
+				continue;
+			}
+
+			$sct = $calendarObject->getSchedulingTransparency();
+			if ($sct !== null && ScheduleCalendarTransp::TRANSPARENT == strtolower($sct->getValue())) {
+				// If a calendar is marked as 'transparent', it means we must
+				// ignore it for free-busy purposes.
+				continue;
+			}
+
+			/** @var Component\VTimeZone|null $ctz */
+			$ctz = $calendarObject->getSchedulingTimezone();
+			if ($ctz !== null) {
+				$calendarTimeZone = $ctz->getTimeZone();
+			}
+			$query->addSearchCalendar($calendarObject->getUri());
+		}
+
+		$calendarEvents = [];
+		$dtStart = $now;
+		$dtEnd = \DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime('+10 minutes'));
+
+		// Only query the calendars when there's any to search
+		if($query instanceof CalendarQuery && !empty($query->getCalendarUris())) {
+			// Query the next hour
+			$query->setTimerangeStart($dtStart);
+			$query->setTimerangeEnd($dtEnd);
+			$calendarEvents = $this->calendarManager->searchForPrincipal($query);
+		}
+
+		// @todo we can cache that
+		if(empty($availability) && empty($calendarEvents)) {
+			// No availability settings and no calendar events, we can stop here
+			return null;
+		}
+
+		$calendar = $this->generator->getVCalendar();
+		foreach ($calendarEvents as $calendarEvent) {
+			$vEvent = new VEvent($calendar, 'VEVENT');
+			foreach($calendarEvent['objects'] as $component) {
+				foreach ($component as $key =>  $value) {
+					$vEvent->add($key, $value[0]);
+				}
+			}
+			$calendar->add($vEvent);
+		}
+
+		$calendar->METHOD = 'REQUEST';
+
+		$this->generator->setObjects($calendar);
+		$this->generator->setTimeRange($dtStart, $dtEnd);
+		$this->generator->setTimeZone($calendarTimeZone);
+
+		if (!empty($availability)) {
+			$this->generator->setVAvailability(
+				Reader::read(
+					$availability
+				)
+			);
+		}
+		// Generate the intersection of VAVILABILITY and all VEVENTS in all calendars
+		$result = $this->generator->getResult();
+
+		if (!isset($result->VFREEBUSY)) {
+			return null;
+		}
+
+		/** @var Component $freeBusyComponent */
+		$freeBusyComponent = $result->VFREEBUSY;
+		$freeBusyProperties = $freeBusyComponent->select('FREEBUSY');
+		// If there is no FreeBusy property, the time-range is empty and available
+		// so set the status to online as otherwise we will never recover from a BUSY status
+		if (count($freeBusyProperties) === 0) {
+			return new Status(IUserStatus::ONLINE);
+		}
+
+		/** @var Property $freeBusyProperty */
+		$freeBusyProperty = $freeBusyProperties[0];
+		if (!$freeBusyProperty->offsetExists('FBTYPE')) {
+			// If there is no FBTYPE, it means it's busy from a regular event
+			return new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY);
+		}
+
+		// If we can't deal with the FBTYPE (custom properties are a possibility)
+		// we should ignore it and leave the current status
+		$fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE');
+		if (!($fbTypeParameter instanceof Parameter)) {
+			return null;
+		}
+		$fbType = $fbTypeParameter->getValue();
+		switch ($fbType) {
+			case 'BUSY':
+				return new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY, $this->l10n->t('In a meeting'));
+			case 'BUSY-UNAVAILABLE':
+				return new Status(IUserStatus::AWAY, IUserStatus::MESSAGE_AVAILABILITY);
+			case 'BUSY-TENTATIVE':
+				return new Status(IUserStatus::AWAY, IUserStatus::MESSAGE_CALENDAR_BUSY_TENTATIVE);
+			default:
+				return null;
+		}
+	}
+}

+ 1508 - 0
apps/dav/tests/unit/CalDAV/Status/StatusServiceTest.php

@@ -0,0 +1,1508 @@
+<?php
+/**
+ * @copyright 2023 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+namespace OCA\DAV\Tests\unit\CalDAV\Status;
+
+use OC\Calendar\CalendarQuery;
+use OCA\DAV\CalDAV\CalendarImpl;
+use OCA\DAV\CalDAV\FreeBusy\FreeBusyGenerator;
+use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
+use OCA\DAV\CalDAV\Schedule\Plugin;
+use OCA\DAV\CalDAV\Status\Status;
+use OCA\DAV\CalDAV\Status\StatusService;
+use OCA\DAV\Connector\Sabre\Server;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\Calendar\IManager;
+use OCP\IL10N;
+use OCP\IUser;
+use OCP\UserStatus\IUserStatus;
+use PHPUnit\Framework\MockObject\MockObject;
+use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
+use Sabre\DAV\Exception\NotAuthenticated;
+use Sabre\DAV\Xml\Property\LocalHref;
+use Sabre\DAVACL\Exception\NeedPrivileges;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VTimeZone;
+use Sabre\VObject\Document;
+use Sabre\VObject\Reader;
+use Test\TestCase;
+
+class StatusServiceTest extends TestCase {
+	private ITimeFactory|MockObject $timeFactory;
+	private IManager|MockObject $calendarManager;
+	private InvitationResponseServer|MockObject $server;
+	private IL10N|MockObject $l10n;
+	private FreeBusyGenerator|MockObject $generator;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->timeFactory = $this->createMock(ITimeFactory::class);
+		$this->calendarManager = $this->createMock(IManager::class);
+		$this->server = $this->createMock(InvitationResponseServer::class);
+		$this->l10n = $this->createMock(IL10N::class);
+		$this->generator = $this->createMock(FreeBusyGenerator::class);
+
+		$this->service = new StatusService($this->timeFactory,
+			$this->calendarManager,
+			$this->server,
+			$this->l10n,
+			$this->generator);
+	}
+
+	public function testNoEmail(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => null,
+		]);
+		$availability = '';
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn(null);
+		$this->server->expects(self::never())
+			->method('getServer');
+		$this->timeFactory->expects(self::never())
+			->method('now');
+		$this->timeFactory->expects(self::never())
+			->method('getDateTime');
+		$this->calendarManager->expects(self::never())
+			->method('getCalendarsForPrincipal');
+		$this->calendarManager->expects(self::never())
+			->method('newQuery');
+		$this->calendarManager->expects(self::never())
+			->method('searchForPrincipal');
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testNoAcl(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$availability = '';
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with([ '{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn([]);
+		$aclPlugin->expects(self::never())
+			->method('checkPrivileges');
+		$this->timeFactory->expects(self::never())
+			->method('now');
+		$this->timeFactory->expects(self::never())
+			->method('getDateTime');
+		$this->calendarManager->expects(self::never())
+			->method('getCalendarsForPrincipal');
+		$this->calendarManager->expects(self::never())
+			->method('newQuery');
+		$this->calendarManager->expects(self::never())
+			->method('searchForPrincipal');
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testNoInbox(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$availability = '';
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with([ '{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn([]);
+		$aclPlugin->expects(self::never())
+			->method('checkPrivileges');
+		$this->timeFactory->expects(self::never())
+			->method('now');
+		$this->timeFactory->expects(self::never())
+			->method('getDateTime');
+		$this->calendarManager->expects(self::never())
+			->method('getCalendarsForPrincipal');
+		$this->calendarManager->expects(self::never())
+			->method('newQuery');
+		$this->calendarManager->expects(self::never())
+			->method('searchForPrincipal');
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testNoPrivilegesAcl(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$availability = '';
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$principal = 'principals/users/admin';
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with([ '{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willThrowException(new NeedPrivileges($principal, ['{DAV:}all']));
+		$this->timeFactory->expects(self::never())
+			->method('now');
+		$this->timeFactory->expects(self::never())
+			->method('getDateTime');
+		$this->calendarManager->expects(self::never())
+			->method('getCalendarsForPrincipal');
+		$this->calendarManager->expects(self::never())
+			->method('newQuery');
+		$this->calendarManager->expects(self::never())
+			->method('searchForPrincipal');
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testNotAuthenticated(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$availability = '';
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with([ '{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willThrowException(new NotAuthenticated());
+		$this->timeFactory->expects(self::never())
+			->method('now');
+		$this->timeFactory->expects(self::never())
+			->method('getDateTime');
+		$this->calendarManager->expects(self::never())
+			->method('getCalendarsForPrincipal');
+		$this->calendarManager->expects(self::never())
+			->method('newQuery');
+		$this->calendarManager->expects(self::never())
+			->method('searchForPrincipal');
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testNoCalendars(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$availability = '';
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1', new \DateTimeZone('UTC'));
+		$principal = 'principals/users/admin';
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with([ '{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([]);
+		$this->timeFactory->expects(self::never())
+			->method('getDateTime');
+		$this->calendarManager->expects(self::never())
+			->method('newQuery');
+		$this->calendarManager->expects(self::never())
+			->method('searchForPrincipal');
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testEmptyAvailabilityAndNoSearchCalendars(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$availability = '';
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$now = new \DateTimeImmutable('1970-1-1', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$principal = 'principals/users/admin';
+		$calendar = $this->createMock(CalendarImpl::class);
+		$query = $this->createMock(CalendarQuery::class);
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with([ '{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('transparent'));
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$this->calendarManager->expects(self::never())
+			->method('searchForPrincipal');
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testEmptyAvailabilityAndSearchCalendarsNoResults(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$availability = '';
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::never())
+			->method('getVCalendar');
+		$this->generator->expects(self::never())
+			->method('setObjects');
+		$this->generator->expects(self::never())
+			->method('setTimeRange');
+		$this->generator->expects(self::never())
+			->method('setTimeZone');
+		$this->generator->expects(self::never())
+			->method('setVAvailability');
+		$this->generator->expects(self::never())
+			->method('getResult');
+
+		$status = $this->service->processCalendarAvailability($user, $availability);
+		$this->assertNull($status);
+	}
+
+	public function testAvailabilityAndSearchCalendarsNoResults(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+		$vCalendar = $this->createMock(VCalendar::class);
+		$availability = $this->getVAvailability();
+		$result = Reader::read('BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.3//EN
+		CALSCALE:GREGORIAN
+METHOD:REQUEST
+END:VCALENDAR');
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::once())
+			->method('getVCalendar')
+			->willReturn($vCalendar);
+		$vCalendar->expects(self::never())
+			->method('add');
+		$this->generator->expects(self::once())
+			->method('setObjects')
+			->with($vCalendar);
+		$this->generator->expects(self::once())
+			->method('setTimeRange')
+			->with($now, $immutableInTenMinutes);
+		$this->generator->expects(self::once())
+			->method('setTimeZone')
+			->with($timezone);
+		$this->generator->expects(self::once())
+			->method('setVAvailability')
+			->with($availability);
+		$this->generator->expects(self::once())
+			->method('getResult')
+			->willReturn($result);
+
+		$status = $this->service->processCalendarAvailability($user, $availability->serialize());
+		$this->assertNull($status);
+	}
+
+	public function testAvailabilityAndSearchCalendarsStatusOnline(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+		$vCalendar = $this->createMock(VCalendar::class);
+		$availability = $this->getVAvailability();
+		$result = Reader::read('BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.3//EN
+		CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VFREEBUSY
+DTSTART:19700101T000000Z
+DTEND:19700101T003600Z
+DTSTAMP:19700101T000200Z
+END:VFREEBUSY
+END:VCALENDAR');
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::once())
+			->method('getVCalendar')
+			->willReturn($vCalendar);
+		$vCalendar->expects(self::never())
+			->method('add');
+		$this->generator->expects(self::once())
+			->method('setObjects')
+			->with($vCalendar);
+		$this->generator->expects(self::once())
+			->method('setTimeRange')
+			->with($now, $immutableInTenMinutes);
+		$this->generator->expects(self::once())
+			->method('setTimeZone')
+			->with($timezone);
+		$this->generator->expects(self::once())
+			->method('setVAvailability')
+			->with($availability);
+		$this->generator->expects(self::once())
+			->method('getResult')
+			->willReturn($result);
+
+		$status = $this->service->processCalendarAvailability($user, $availability->serialize());
+		$this->assertEquals(new Status(IUserStatus::ONLINE), $status);
+	}
+
+	public function testAvailabilityAndSearchCalendarsStatusBusyNoFBType(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+		$vCalendar = $this->createMock(VCalendar::class);
+		$availability = $this->getVAvailability();
+		$result = Reader::read('BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.3//EN
+		CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VFREEBUSY
+DTSTART:19700101T000000Z
+DTEND:19700101T003600Z
+DTSTAMP:19700101T000200Z
+FREEBUSY:19700101T000000Z/19700101T003600Z
+END:VFREEBUSY
+END:VCALENDAR');
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::once())
+			->method('getVCalendar')
+			->willReturn($vCalendar);
+		$vCalendar->expects(self::never())
+			->method('add');
+		$this->generator->expects(self::once())
+			->method('setObjects')
+			->with($vCalendar);
+		$this->generator->expects(self::once())
+			->method('setTimeRange')
+			->with($now, $immutableInTenMinutes);
+		$this->generator->expects(self::once())
+			->method('setTimeZone')
+			->with($timezone);
+		$this->generator->expects(self::once())
+			->method('setVAvailability')
+			->with($availability);
+		$this->generator->expects(self::once())
+			->method('getResult')
+			->willReturn($result);
+
+		$status = $this->service->processCalendarAvailability($user, $availability->serialize());
+		$this->assertEquals(new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY), $status);
+	}
+
+	public function testAvailabilityAndSearchCalendarsStatusBusy(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+		$vCalendar = $this->createMock(VCalendar::class);
+		$availability = $this->getVAvailability();
+		$result = Reader::read('BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.3//EN
+		CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VFREEBUSY
+DTSTART:19700101T000000Z
+DTEND:19700101T003600Z
+DTSTAMP:19700101T000200Z
+FREEBUSY;FBTYPE=BUSY:19700101T000000Z/19700101T003600Z
+END:VFREEBUSY
+END:VCALENDAR');
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::once())
+			->method('getVCalendar')
+			->willReturn($vCalendar);
+		$vCalendar->expects(self::never())
+			->method('add');
+		$this->generator->expects(self::once())
+			->method('setObjects')
+			->with($vCalendar);
+		$this->generator->expects(self::once())
+			->method('setTimeRange')
+			->with($now, $immutableInTenMinutes);
+		$this->generator->expects(self::once())
+			->method('setTimeZone')
+			->with($timezone);
+		$this->generator->expects(self::once())
+			->method('setVAvailability')
+			->with($availability);
+		$this->generator->expects(self::once())
+			->method('getResult')
+			->willReturn($result);
+		$this->l10n->expects(self::once())
+			->method('t')
+			->with('In a meeting')
+			->willReturn('In a meeting');
+
+		$status = $this->service->processCalendarAvailability($user, $availability->serialize());
+		$this->assertEquals(new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY, 'In a meeting'), $status);
+	}
+
+	public function testAvailabilityAndSearchCalendarsStatusBusyUnavailable(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+		$vCalendar = $this->createMock(VCalendar::class);
+		$availability = $this->getVAvailability();
+		$result = Reader::read('BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.3//EN
+		CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VFREEBUSY
+DTSTART:19700101T000000Z
+DTEND:19700101T003600Z
+DTSTAMP:19700101T000200Z
+FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19700101T000000Z/19700101T003600Z
+END:VFREEBUSY
+END:VCALENDAR');
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::once())
+			->method('getVCalendar')
+			->willReturn($vCalendar);
+		$vCalendar->expects(self::never())
+			->method('add');
+		$this->generator->expects(self::once())
+			->method('setObjects')
+			->with($vCalendar);
+		$this->generator->expects(self::once())
+			->method('setTimeRange')
+			->with($now, $immutableInTenMinutes);
+		$this->generator->expects(self::once())
+			->method('setTimeZone')
+			->with($timezone);
+		$this->generator->expects(self::once())
+			->method('setVAvailability')
+			->with($availability);
+		$this->generator->expects(self::once())
+			->method('getResult')
+			->willReturn($result);
+		$this->l10n->expects(self::never())
+			->method('t');
+		$status = $this->service->processCalendarAvailability($user, $availability->serialize());
+		$this->assertEquals(new Status(IUserStatus::AWAY, IUserStatus::MESSAGE_AVAILABILITY), $status);
+	}
+
+	public function testAvailabilityAndSearchCalendarsStatusBusyTentative(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+		$vCalendar = $this->createMock(VCalendar::class);
+		$availability = $this->getVAvailability();
+		$result = Reader::read('BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.3//EN
+		CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VFREEBUSY
+DTSTART:19700101T000000Z
+DTEND:19700101T003600Z
+DTSTAMP:19700101T000200Z
+FREEBUSY;FBTYPE=BUSY-TENTATIVE:19700101T000000Z/19700101T003600Z
+END:VFREEBUSY
+END:VCALENDAR');
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::once())
+			->method('getVCalendar')
+			->willReturn($vCalendar);
+		$vCalendar->expects(self::never())
+			->method('add');
+		$this->generator->expects(self::once())
+			->method('setObjects')
+			->with($vCalendar);
+		$this->generator->expects(self::once())
+			->method('setTimeRange')
+			->with($now, $immutableInTenMinutes);
+		$this->generator->expects(self::once())
+			->method('setTimeZone')
+			->with($timezone);
+		$this->generator->expects(self::once())
+			->method('setVAvailability')
+			->with($availability);
+		$this->generator->expects(self::once())
+			->method('getResult')
+			->willReturn($result);
+		$this->l10n->expects(self::never())
+			->method('t');
+		$status = $this->service->processCalendarAvailability($user, $availability->serialize());
+		$this->assertEquals(new Status(IUserStatus::AWAY, IUserStatus::MESSAGE_CALENDAR_BUSY_TENTATIVE), $status);
+	}
+
+	public function testAvailabilityAndSearchCalendarsStatusBusyUnknownProperty(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$server = $this->createMock(Server::class);
+		$schedulingPlugin = $this->createMock(Plugin::class);
+		$aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class);
+		$calendarHome = $this->createMock(LocalHref::class);
+		$acl = [[200 => ['{urn:ietf:params:xml:ns:caldav}schedule-inbox-URL' => $calendarHome]]];
+		$now = new \DateTimeImmutable('1970-1-1 00:00', new \DateTimeZone('UTC'));
+		$inTenMinutes = new \DateTime('1970-1-1 01:00');
+		$immutableInTenMinutes = \DateTimeImmutable::createFromMutable($inTenMinutes);
+		$principal = 'principals/users/admin';
+		$query = $this->createMock(CalendarQuery::class);
+		$timezone = new \DateTimeZone('UTC');
+		$timezoneObj = $this->createMock(VTimeZone::class);
+		$calendar = $this->createMock(CalendarImpl::class);
+		$vCalendar = $this->createMock(VCalendar::class);
+		$availability = $this->getVAvailability();
+		$result = Reader::read('BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.3//EN
+		CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VFREEBUSY
+DTSTART:19700101T000000Z
+DTEND:19700101T003600Z
+DTSTAMP:19700101T000200Z
+FREEBUSY;FBTYPE=X-MEETING:19700101T000000Z/19700101T003600Z
+END:VFREEBUSY
+END:VCALENDAR');
+
+		$user->expects(self::once())
+			->method('getUID')
+			->willReturn('admin');
+		$user->expects(self::once())
+			->method('getEMailAddress')
+			->willReturn('test@test.com');
+		$this->server->expects(self::once())
+			->method('getServer')
+			->willReturn($server);
+		$server->expects(self::exactly(2))
+			->method('getPlugin')
+			->withConsecutive(
+				['caldav-schedule'],
+				['acl'],
+			)->willReturnOnConsecutiveCalls($schedulingPlugin, $aclPlugin);
+		$aclPlugin->expects(self::once())
+			->method('principalSearch')
+			->with(['{http://sabredav.org/ns}email-address' => 'test@test.com'])
+			->willReturn($acl);
+		$calendarHome->expects(self::once())
+			->method('getHref')
+			->willReturn('calendars/admin/inbox/');
+		$aclPlugin->expects(self::once())
+			->method('checkPrivileges')
+			->willReturn(true);
+		$this->timeFactory->expects(self::once())
+			->method('now')
+			->willReturn($now);
+		$this->calendarManager->expects(self::once())
+			->method('getCalendarsForPrincipal')
+			->with($principal)
+			->willReturn([$calendar]);
+		$this->calendarManager->expects(self::once())
+			->method('newQuery')
+			->with($principal)
+			->willReturn($query);
+		$calendar->expects(self::once())
+			->method('getSchedulingTransparency')
+			->willReturn(new ScheduleCalendarTransp('opaque'));
+		$calendar->expects(self::once())
+			->method('getSchedulingTimezone')
+			->willReturn($timezoneObj);
+		$timezoneObj->expects(self::once())
+			->method('getTimeZone')
+			->willReturn($timezone);
+		$calendar->expects(self::once())
+			->method('getUri');
+		$query->expects(self::once())
+			->method('addSearchCalendar');
+		$query->expects(self::once())
+			->method('getCalendarUris')
+			->willReturn([$calendar]);
+		$this->timeFactory->expects(self::once())
+			->method('getDateTime')
+			->with('+10 minutes')
+			->willReturn($inTenMinutes);
+		$query->expects(self::once())
+			->method('setTimerangeStart')
+			->with($now);
+		$query->expects(self::once())
+			->method('setTimerangeEnd')
+			->with($immutableInTenMinutes);
+		$this->calendarManager->expects(self::once())
+			->method('searchForPrincipal')
+			->with($query)
+			->willReturn([]);
+		$this->generator->expects(self::once())
+			->method('getVCalendar')
+			->willReturn($vCalendar);
+		$vCalendar->expects(self::never())
+			->method('add');
+		$this->generator->expects(self::once())
+			->method('setObjects')
+			->with($vCalendar);
+		$this->generator->expects(self::once())
+			->method('setTimeRange')
+			->with($now, $immutableInTenMinutes);
+		$this->generator->expects(self::once())
+			->method('setTimeZone')
+			->with($timezone);
+		$this->generator->expects(self::once())
+			->method('setVAvailability')
+			->with($availability);
+		$this->generator->expects(self::once())
+			->method('getResult')
+			->willReturn($result);
+		$this->l10n->expects(self::never())
+			->method('t');
+		$status = $this->service->processCalendarAvailability($user, $availability->serialize());
+		$this->assertNull($status);
+	}
+
+	private function getVAvailability(): Document {
+		return Reader::read('BEGIN:VCALENDAR
+PRODID:Nextcloud DAV app
+BEGIN:VTIMEZONE
+TZID:Europe/Vienna
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VAVAILABILITY
+BEGIN:AVAILABLE
+DTSTART;TZID=Europe/Vienna:20231025T000000
+DTEND;TZID=Europe/Vienna:20231025T235900
+UID:d866782e-e003-4906-9ece-303f270a2c6b
+RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU
+END:AVAILABLE
+END:VAVAILABILITY
+END:VCALENDAR');
+	}
+}

+ 20 - 0
apps/user_status/lib/Db/UserStatusMapper.php

@@ -26,6 +26,7 @@ declare(strict_types=1);
 
 namespace OCA\UserStatus\Db;
 
+use Sabre\CalDAV\Schedule\Plugin;
 use OCP\AppFramework\Db\QBMapper;
 use OCP\DB\QueryBuilder\IQueryBuilder;
 use OCP\IDBConnection;
@@ -210,4 +211,23 @@ class UserStatusMapper extends QBMapper {
 
 		$qb->executeStatement();
 	}
+
+	public function getAvailabilityFromPropertiesTable(string $userId): ?string {
+		$propertyPath = 'calendars/' . $userId . '/inbox';
+		$propertyName = '{' . Plugin::NS_CALDAV . '}calendar-availability';
+
+		$query = $this->db->getQueryBuilder();
+		$query->select('propertyvalue')
+			->from('properties')
+			->where($query->expr()->eq('userid', $query->createNamedParameter($userId)))
+			->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($propertyPath)))
+			->andWhere($query->expr()->eq('propertyname', $query->createNamedParameter($propertyName)))
+			->setMaxResults(1);
+
+		$result = $query->executeQuery();
+		$property = $result->fetchOne();
+		$result->closeCursor();
+
+		return ($property === false ? null : $property);
+	}
 }

+ 1 - 1
apps/user_status/lib/Listener/UserLiveStatusListener.php

@@ -74,7 +74,7 @@ class UserLiveStatusListener implements IEventListener {
 			$userStatus->setIsUserDefined(false);
 		}
 
-		// If the status is user-defined and one of the persistent statuses, we
+		// If the status is user-defined and one of the persistent status, we
 		// will not override it.
 		if ($userStatus->getIsUserDefined() &&
 			\in_array($userStatus->getStatus(), StatusService::PERSISTENT_STATUSES, true)) {

+ 2 - 0
apps/user_status/lib/Service/PredefinedStatusService.php

@@ -202,6 +202,8 @@ class PredefinedStatusService {
 			self::REMOTE_WORK,
 			IUserStatus::MESSAGE_CALL,
 			IUserStatus::MESSAGE_AVAILABILITY,
+			IUserStatus::MESSAGE_CALENDAR_BUSY,
+			IUserStatus::MESSAGE_CALENDAR_BUSY_TENTATIVE,
 		], true);
 	}
 }

+ 96 - 44
apps/user_status/lib/Service/StatusService.php

@@ -7,6 +7,7 @@ declare(strict_types=1);
  *
  * @author Georg Ehrke <oc.list@georgehrke.com>
  * @author Joas Schilling <coding@schilljs.com>
+ * @author Anna Larch <anna.larch@gmx.net>
  *
  * @license GNU AGPL version 3 or any later version
  *
@@ -26,6 +27,8 @@ declare(strict_types=1);
  */
 namespace OCA\UserStatus\Service;
 
+use OCA\DAV\CalDAV\Status\Status as CalendarStatus;
+use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
 use OCA\UserStatus\Db\UserStatus;
 use OCA\UserStatus\Db\UserStatusMapper;
 use OCA\UserStatus\Exception\InvalidClearAtException;
@@ -35,10 +38,13 @@ use OCA\UserStatus\Exception\InvalidStatusTypeException;
 use OCA\UserStatus\Exception\StatusMessageTooLongException;
 use OCP\AppFramework\Db\DoesNotExistException;
 use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\Calendar\ISchedulingInformation;
 use OCP\DB\Exception;
 use OCP\IConfig;
 use OCP\IEmojiHelper;
+use OCP\IUserManager;
 use OCP\UserStatus\IUserStatus;
+use function in_array;
 
 /**
  * Class StatusService
@@ -46,26 +52,9 @@ use OCP\UserStatus\IUserStatus;
  * @package OCA\UserStatus\Service
  */
 class StatusService {
-
-	/** @var UserStatusMapper */
-	private $mapper;
-
-	/** @var ITimeFactory */
-	private $timeFactory;
-
-	/** @var PredefinedStatusService */
-	private $predefinedStatusService;
-
-	private IEmojiHelper $emojiHelper;
-
-	/** @var bool */
-	private $shareeEnumeration;
-
-	/** @var bool */
-	private $shareeEnumerationInGroupOnly;
-
-	/** @var bool */
-	private $shareeEnumerationPhone;
+	private bool $shareeEnumeration;
+	private bool $shareeEnumerationInGroupOnly;
+	private bool $shareeEnumerationPhone;
 
 	/**
 	 * List of priorities ordered by their priority
@@ -74,6 +63,7 @@ class StatusService {
 		IUserStatus::ONLINE,
 		IUserStatus::AWAY,
 		IUserStatus::DND,
+		IUserStatus::BUSY,
 		IUserStatus::INVISIBLE,
 		IUserStatus::OFFLINE,
 	];
@@ -84,6 +74,7 @@ class StatusService {
 	 */
 	public const PERSISTENT_STATUSES = [
 		IUserStatus::AWAY,
+		IUserStatus::BUSY,
 		IUserStatus::DND,
 		IUserStatus::INVISIBLE,
 	];
@@ -94,18 +85,16 @@ class StatusService {
 	/** @var int */
 	public const MAXIMUM_MESSAGE_LENGTH = 80;
 
-	public function __construct(UserStatusMapper $mapper,
-								ITimeFactory $timeFactory,
-								PredefinedStatusService $defaultStatusService,
-								IEmojiHelper $emojiHelper,
-								IConfig $config) {
-		$this->mapper = $mapper;
-		$this->timeFactory = $timeFactory;
-		$this->predefinedStatusService = $defaultStatusService;
-		$this->emojiHelper = $emojiHelper;
-		$this->shareeEnumeration = $config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
-		$this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
-		$this->shareeEnumerationPhone = $this->shareeEnumeration && $config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
+	public function __construct(private UserStatusMapper $mapper,
+								private ITimeFactory $timeFactory,
+								private PredefinedStatusService $predefinedStatusService,
+								private IEmojiHelper $emojiHelper,
+								private IConfig $config,
+								private IUserManager $userManager,
+								private CalendarStatusService $calendarStatusService) {
+		$this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
+		$this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
+		$this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
 	}
 
 	/**
@@ -149,8 +138,37 @@ class StatusService {
 	 * @return UserStatus
 	 * @throws DoesNotExistException
 	 */
-	public function findByUserId(string $userId):UserStatus {
-		return $this->processStatus($this->mapper->findByUserId($userId));
+	public function findByUserId(string $userId): UserStatus {
+		$userStatus = $this->mapper->findByUserId($userId);
+		// If the status is user-defined and one of the persistent status, we
+		// will not override it.
+		if ($userStatus->getIsUserDefined() && \in_array($userStatus->getStatus(), StatusService::PERSISTENT_STATUSES, true)) {
+			return $this->processStatus($userStatus);
+		}
+
+		$calendarStatus = $this->getCalendarStatus($userId);
+		// We found no status from the calendar, proceed with the existing status
+		if($calendarStatus === null) {
+			return $this->processStatus($userStatus);
+		}
+
+		// if we have the same status result for the calendar and the current status,
+		// and a custom message to boot, we leave the existing status alone
+		// as to not overwrite a custom message / emoji
+		if($userStatus->getIsUserDefined()
+			&& $calendarStatus->getStatus() === $userStatus->getStatus()
+			&& !empty($userStatus->getCustomMessage())) {
+			return $this->processStatus($userStatus);
+		}
+
+		// If the new status is null, there's already an identical status in place
+		$newUserStatus = $this->setUserStatus($userId,
+			$calendarStatus->getStatus(),
+			$calendarStatus->getMessage() ?? IUserStatus::MESSAGE_AVAILABILITY,
+			true,
+			$calendarStatus->getCustomMessage() ?? '');
+
+		return $newUserStatus === null ? $this->processStatus($userStatus) : $this->processStatus($newUserStatus);
 	}
 
 	/**
@@ -183,9 +201,12 @@ class StatusService {
 		}
 
 		// Check if status-type is valid
-		if (!\in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
+		if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
 			throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
 		}
+
+
+
 		if ($statusTimestamp === null) {
 			$statusTimestamp = $this->timeFactory->getTime();
 		}
@@ -255,11 +276,12 @@ class StatusService {
 	 * @throws InvalidMessageIdException
 	 */
 	public function setUserStatus(string $userId,
-										 string $status,
-										 string $messageId,
-										 bool $createBackup): void {
+									string $status,
+									string $messageId,
+								 	bool $createBackup,
+									string $customMessage = null): ?UserStatus {
 		// Check if status-type is valid
-		if (!\in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
+		if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
 			throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
 		}
 
@@ -269,7 +291,7 @@ class StatusService {
 
 		if ($createBackup) {
 			if ($this->backupCurrentStatus($userId) === false) {
-				return; // Already a status set automatically => abort.
+				return null; // Already a status set automatically => abort.
 			}
 
 			// If we just created the backup
@@ -290,15 +312,14 @@ class StatusService {
 		$userStatus->setIsBackup(false);
 		$userStatus->setMessageId($messageId);
 		$userStatus->setCustomIcon(null);
-		$userStatus->setCustomMessage(null);
+		$userStatus->setCustomMessage($customMessage);
 		$userStatus->setClearAt(null);
 		$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
 
 		if ($userStatus->getId() !== null) {
-			$this->mapper->update($userStatus);
-			return;
+			return $this->mapper->update($userStatus);
 		}
-		$this->mapper->insert($userStatus);
+		return $this->mapper->insert($userStatus);
 	}
 
 	/**
@@ -561,4 +582,35 @@ class StatusService {
 		// For users that matched restore the previous status
 		$this->mapper->restoreBackupStatuses($restoreIds);
 	}
+
+	/**
+	 * Calculate a users' status according to their availabilit settings and their calendar
+	 * events
+	 *
+	 * There are 4 predefined types of FBTYPE - 'FREE', 'BUSY', 'BUSY-UNAVAILABLE', 'BUSY-TENTATIVE',
+	 * but 'X-' properties are possible
+	 *
+	 * @link https://icalendar.org/iCalendar-RFC-5545/3-2-9-free-busy-time-type.html
+	 *
+	 * The status will be changed for types
+	 *  - 'BUSY'
+	 *  - 'BUSY-UNAVAILABLE' (ex.: when a VAVILABILITY setting is in effect)
+	 *  - 'BUSY-TENTATIVE' (ex.: an event has been accepted tentatively)
+	 * and all FREEBUSY components without a type (implicitly a 'BUSY' status)
+	 *
+	 * 'X-' properties are not handled for now
+	 *
+	 * @param string $userId
+	 * @return CalendarStatus|null
+	 */
+	public function getCalendarStatus(string $userId): ?CalendarStatus {
+		$user = $this->userManager->get($userId);
+		if ($user === null) {
+			return null;
+		}
+
+		$availability = $this->mapper->getAvailabilityFromPropertiesTable($userId);
+
+		return $this->calendarStatusService->processCalendarAvailability($user, $availability);
+	}
 }

+ 2 - 0
apps/user_status/src/mixins/OnlineStatusMixin.js

@@ -52,6 +52,7 @@ export default {
 					return this.$t('user_status', 'Online')
 
 				case 'away':
+				case 'busy':
 					return this.$t('user_status', 'Away')
 
 				case 'dnd':
@@ -79,6 +80,7 @@ export default {
 				return 'icon-user-status-online'
 
 			case 'away':
+			case 'busy':
 				return 'icon-user-status-away'
 
 			case 'dnd':

+ 325 - 20
apps/user_status/tests/Unit/Service/StatusServiceTest.php

@@ -28,6 +28,9 @@ namespace OCA\UserStatus\Tests\Service;
 
 use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
 use OC\DB\Exceptions\DbalException;
+use OC\User\User;
+use OCA\DAV\CalDAV\Status\Status;
+use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
 use OCA\UserStatus\Db\UserStatus;
 use OCA\UserStatus\Db\UserStatusMapper;
 use OCA\UserStatus\Exception\InvalidClearAtException;
@@ -40,30 +43,40 @@ use OCA\UserStatus\Service\StatusService;
 use OCP\AppFramework\Db\DoesNotExistException;
 use OCP\AppFramework\Utility\ITimeFactory;
 use OCP\DB\Exception;
+use OCP\EventDispatcher\IEventDispatcher;
 use OCP\IConfig;
 use OCP\IEmojiHelper;
+use OCP\IUser;
+use OCP\IUserBackend;
+use OCP\IUserManager;
 use OCP\UserStatus\IUserStatus;
+use PHPUnit\Framework\MockObject\MockObject;
 use Test\TestCase;
 
 class StatusServiceTest extends TestCase {
 
-	/** @var UserStatusMapper|\PHPUnit\Framework\MockObject\MockObject */
+	/** @var UserStatusMapper|MockObject */
 	private $mapper;
 
-	/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
+	/** @var ITimeFactory|MockObject */
 	private $timeFactory;
 
-	/** @var PredefinedStatusService|\PHPUnit\Framework\MockObject\MockObject */
+	/** @var PredefinedStatusService|MockObject */
 	private $predefinedStatusService;
 
-	/** @var IEmojiHelper|\PHPUnit\Framework\MockObject\MockObject */
+	/** @var IEmojiHelper|MockObject */
 	private $emojiHelper;
 
-	/** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */
+	/** @var IConfig|MockObject */
 	private $config;
 
-	/** @var StatusService */
-	private $service;
+	/** @var IUserManager|MockObject  */
+	private $userManager;
+
+	/** @var CalendarStatusService|MockObject  */
+	private $calendarStatusService;
+
+	private StatusService $service;
 
 	protected function setUp(): void {
 		parent::setUp();
@@ -72,6 +85,8 @@ class StatusServiceTest extends TestCase {
 		$this->timeFactory = $this->createMock(ITimeFactory::class);
 		$this->predefinedStatusService = $this->createMock(PredefinedStatusService::class);
 		$this->emojiHelper = $this->createMock(IEmojiHelper::class);
+		$this->userManager = $this->createMock(IUserManager::class);
+		$this->calendarStatusService = $this->createMock(CalendarStatusService::class);
 
 		$this->config = $this->createMock(IConfig::class);
 
@@ -85,7 +100,10 @@ class StatusServiceTest extends TestCase {
 			$this->timeFactory,
 			$this->predefinedStatusService,
 			$this->emojiHelper,
-			$this->config);
+			$this->config,
+			$this->userManager,
+			$this->calendarStatusService,
+		);
 	}
 
 	public function testFindAll(): void {
@@ -139,7 +157,10 @@ class StatusServiceTest extends TestCase {
 			$this->timeFactory,
 			$this->predefinedStatusService,
 			$this->emojiHelper,
-			$this->config);
+			$this->config,
+			$this->userManager,
+			$this->calendarStatusService,
+		);
 
 		$this->assertEquals([], $this->service->findAllRecentStatusChanges(20, 50));
 
@@ -156,21 +177,14 @@ class StatusServiceTest extends TestCase {
 			$this->timeFactory,
 			$this->predefinedStatusService,
 			$this->emojiHelper,
-			$this->config);
+			$this->config,
+			$this->userManager,
+			$this->calendarStatusService,
+		);
 
 		$this->assertEquals([], $this->service->findAllRecentStatusChanges(20, 50));
 	}
 
-	public function testFindByUserId(): void {
-		$status = $this->createMock(UserStatus::class);
-		$this->mapper->expects($this->once())
-			->method('findByUserId')
-			->with('john.doe')
-			->willReturn($status);
-
-		$this->assertEquals($status, $this->service->findByUserId('john.doe'));
-	}
-
 	public function testFindByUserIdDoesNotExist(): void {
 		$this->mapper->expects($this->once())
 			->method('findByUserId')
@@ -825,4 +839,295 @@ class StatusServiceTest extends TestCase {
 
 		$this->service->revertMultipleUserStatus(['john', 'nobackup', 'backuponly', 'nobackupanddnd'], 'call');
 	}
+
+	public function testCalendarAvailabilityNoUser(): void {
+		$userId = 'admin';
+
+		$this->userManager->expects(self::once())
+			->method('get')
+			->with($userId)
+			->willReturn(null);
+		$this->mapper->expects(self::never())
+			->method('getAvailabilityFromPropertiesTable');
+		$this->calendarStatusService->expects(self::never())
+			->method('processCalendarAvailability');
+
+		$this->service->getCalendarStatus($userId);
+	}
+
+	public function testCalendarAvailabilityNoVavailablility(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+
+		$this->userManager->expects(self::once())
+			->method('get')
+			->with($user->getUID())
+			->willReturn($user);
+		$this->mapper->expects(self::once())
+			->method('getAvailabilityFromPropertiesTable')
+			->willReturn('');
+		$this->calendarStatusService->expects(self::once())
+			->method('processCalendarAvailability')
+			->with($user, '')
+			->willReturn(null);
+
+		$this->service->getCalendarStatus($user->getUID());
+	}
+
+	public function testCalendarAvailabilityVavailablilityAvailable(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+
+		$vavailability = <<<EOF
+BEGIN:VCALENDAR
+PRODID:Nextcloud DAV app
+BEGIN:VTIMEZONE
+TZID:Europe/Vienna
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VAVAILABILITY
+BEGIN:AVAILABLE
+DTSTART;TZID=Europe/Vienna:20231025T000000
+DTEND;TZID=Europe/Vienna:20231025T235900
+UID:d866782e-e003-4906-9ece-303f270a2c6b
+RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU
+END:AVAILABLE
+END:VAVAILABILITY
+END:VCALENDAR
+EOF;
+		$status = new Status(IUserStatus::AWAY);
+		$this->userManager->expects(self::once())
+			->method('get')
+			->with($user->getUID())
+			->willReturn($user);
+		$this->mapper->expects(self::once())
+			->method('getAvailabilityFromPropertiesTable')
+			->willReturn($vavailability);
+		$this->calendarStatusService->expects(self::once())
+			->method('processCalendarAvailability')
+			->with($user, $vavailability)
+			->willReturn($status);
+
+		$this->service->getCalendarStatus($user->getUID());
+	}
+
+	public function testCalendarAvailabilityVavailablilityUpdate(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$calDavStatus = new Status(IUserStatus::BUSY, 'meeting', 'In a meeting');
+		$vavailability = <<<EOF
+BEGIN:VCALENDAR
+PRODID:Nextcloud DAV app
+BEGIN:VTIMEZONE
+TZID:Europe/Vienna
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VAVAILABILITY
+BEGIN:AVAILABLE
+DTSTART;TZID=Europe/Vienna:20231025T000000
+DTEND;TZID=Europe/Vienna:20231025T235900
+UID:d866782e-e003-4906-9ece-303f270a2c6b
+RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU
+END:AVAILABLE
+END:VAVAILABILITY
+END:VCALENDAR
+EOF;
+		$this->userManager->expects(self::once())
+			->method('get')
+			->with($user->getUID())
+			->willReturn($user);
+		$this->mapper->expects(self::once())
+			->method('getAvailabilityFromPropertiesTable')
+			->willReturn($vavailability);
+		$this->calendarStatusService->expects(self::once())
+			->method('processCalendarAvailability')
+			->with($user, $vavailability)
+			->willReturn($calDavStatus);
+
+		$this->service->getCalendarStatus($user->getUID());
+	}
+
+	public function testFindByUserIdUserDefinedAndPersistent(): void {
+		$status = new UserStatus();
+		$status->setIsUserDefined(true);
+		$status->setStatus(IUserStatus::DND);
+
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('admin')
+			->willReturn($status);
+		$this->mapper->expects(self::never())
+			->method('getAvailabilityFromPropertiesTable');
+		$this->calendarStatusService->expects(self::never())
+			->method('processCalendarAvailability');
+
+		$this->assertEquals($status, $this->service->findByUserId('admin'));
+	}
+
+	public function testFindByUserIdUserDefinedNoCalStatus(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$status = new UserStatus();
+		$status->setIsUserDefined(true);
+		$status->setStatus(IUserStatus::ONLINE);
+
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with($user->getUID())
+			->willReturn($status);
+		$this->userManager->expects(self::once())
+			->method('get')
+			->willReturn($user);
+		$this->mapper->expects(self::once())
+			->method('getAvailabilityFromPropertiesTable')
+			->willReturn('');
+		$this->calendarStatusService->expects(self::once())
+			->method('processCalendarAvailability')
+			->with($user, '')
+			->willReturn(null);
+
+		$this->assertEquals($status, $this->service->findByUserId('admin'));
+	}
+
+	public function testFindByUserIdUserDefinedCalStatusIdentical(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$calDavStatus = new Status(IUserStatus::ONLINE);
+		$userStatus = new UserStatus();
+		$userStatus->setStatus(IUserStatus::ONLINE);
+		$userStatus->setIsUserDefined(true);
+		$userStatus->setCustomMessage('Test');
+
+		$this->mapper->expects(self::once())
+			->method('findByUserId')
+			->with($user->getUID())
+			->willReturn($userStatus);
+		$this->userManager->expects(self::once())
+			->method('get')
+			->willReturn($user);
+		$this->mapper->expects(self::once())
+			->method('getAvailabilityFromPropertiesTable')
+			->willReturn('');
+		$this->calendarStatusService->expects(self::once())
+			->method('processCalendarAvailability')
+			->with($user, '')
+			->willReturn($calDavStatus);
+
+		$this->assertEquals($userStatus, $this->service->findByUserId('admin'));
+	}
+
+	public function testFindByUserIdUserDefinedCalStatusUpdate(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$calDavStatus = new Status(IUserStatus::BUSY, 'meeting', 'In a meeting');
+
+		$oldStatus = new UserStatus();
+		$oldStatus->setId(42);
+		$oldStatus->setUserId($user->getUID());
+		$oldStatus->setStatus(IUserStatus::ONLINE);
+		$oldStatus->setStatusTimestamp(0);
+		$oldStatus->setIsUserDefined(true);
+
+		$expected = new UserStatus();
+		$expected->setUserId($user->getUID());
+		$expected->setStatus(IUserStatus::BUSY);
+		$expected->setStatusTimestamp(0);
+		$expected->setIsUserDefined(true);
+		$expected->setIsBackup(false);
+
+		$this->mapper->expects(self::once())
+			->method('findByUserId')
+			->with($user->getUID())
+			->willReturn($oldStatus);
+		$this->userManager->expects(self::once())
+			->method('get')
+			->willReturn($user);
+		$this->mapper->expects(self::once())
+			->method('getAvailabilityFromPropertiesTable')
+			->willReturn('');
+		$this->mapper->expects(self::once())
+			->method('createBackupStatus')
+			->with($user->getUID())
+			->willReturn(true);
+		$this->calendarStatusService->expects(self::once())
+			->method('processCalendarAvailability')
+			->with($user, '')
+			->willReturn($calDavStatus);
+		$this->predefinedStatusService->expects(self::once())
+			->method('isValidId')
+			->with($calDavStatus->getMessage())
+			->willReturn(true);
+		$this->mapper->expects(self::once())
+			->method('insert')
+			->willReturn($expected);
+
+		$actual = $this->service->findByUserId('admin');
+		$this->assertEquals($expected->getStatus(), $actual->getStatus());
+		$this->assertEquals($expected->getCustomMessage(), $actual->getCustomMessage());
+	}
+
+	public function testFindByUserIdSystemDefined(): void {
+		$user = $this->createConfiguredMock(IUser::class, [
+			'getUID' => 'admin',
+			'getEMailAddress' => 'test@test.com',
+		]);
+		$status = new UserStatus();
+		$status->setIsUserDefined(false);
+		$status->setStatus(IUserStatus::ONLINE);
+
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with($user->getUID())
+			->willReturn($status);
+		$this->userManager->expects(self::once())
+			->method('get')
+			->willReturn($user);
+		$this->mapper->expects(self::once())
+			->method('getAvailabilityFromPropertiesTable')
+			->willReturn('');
+		$this->calendarStatusService->expects(self::once())
+			->method('processCalendarAvailability')
+			->with($user, '')
+			->willReturn(null);
+
+		$this->assertEquals($status, $this->service->findByUserId('admin'));
+	}
 }

File diff ditekan karena terlalu besar
+ 0 - 0
dist/user_status-menu.js


File diff ditekan karena terlalu besar
+ 0 - 0
dist/user_status-menu.js.map


+ 18 - 0
lib/public/UserStatus/IUserStatus.php

@@ -51,6 +51,12 @@ interface IUserStatus {
 	 */
 	public const DND = 'dnd';
 
+	/**
+	 * @var string
+	 * @since 28.0.0
+	 */
+	public const BUSY = 'busy';
+
 	/**
 	 * @var string
 	 * @since 20.0.0
@@ -75,6 +81,18 @@ interface IUserStatus {
 	 */
 	public const MESSAGE_AVAILABILITY = 'availability';
 
+	/**
+	 * @var string
+	 * @since 28.0.0
+	 */
+	public const MESSAGE_CALENDAR_BUSY = 'meeting';
+
+	/**
+	 * @var string
+	 * @since 28.0.0
+	 */
+	public const MESSAGE_CALENDAR_BUSY_TENTATIVE = 'busy-tentative';
+
 	/**
 	 * Get the user this status is connected to
 	 *

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini