diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 8d90df16dbd..117a79cb0fd 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1066,9 +1066,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $calendarType * @return array */ - public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array { + public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, array $fields = []):array { $query = $this->db->getQueryBuilder(); - $query->select(['id','uid', 'etag', 'uri', 'calendardata']) + $query->select($fields ?: ['id', 'uid', 'etag', 'uri', 'calendardata']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) @@ -1077,12 +1077,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $result = []; while (($row = $stmt->fetchAssociative()) !== false) { - $result[$row['uid']] = [ - 'id' => $row['id'], - 'etag' => $row['etag'], - 'uri' => $row['uri'], - 'calendardata' => $row['calendardata'], - ]; + $result[$row['uid']] = $row; } $stmt->closeCursor(); diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php index a3126b20913..052451ef192 100644 --- a/apps/dav/lib/CalDAV/Import/ImportService.php +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -23,9 +23,6 @@ use Sabre\VObject\UUIDUtil; */ class ImportService { - /** @var resource */ - private $source; - public function __construct( private CalDavBackend $backend, ) { @@ -44,18 +41,15 @@ class ImportService { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } - - $this->source = $source; - switch ($options->getFormat()) { case 'ical': - return $this->importProcess($calendar, $options, $this->importText(...)); + return $this->importProcess($source, $calendar, $options, $this->importText(...)); break; case 'jcal': - return $this->importProcess($calendar, $options, $this->importJson(...)); + return $this->importProcess($source, $calendar, $options, $this->importJson(...)); break; case 'xcal': - return $this->importProcess($calendar, $options, $this->importXml(...)); + return $this->importProcess($source, $calendar, $options, $this->importXml(...)); break; default: throw new InvalidArgumentException('Invalid import format'); @@ -65,10 +59,15 @@ class ImportService { /** * Generates object stream from a text formatted source (ical) * + * @param resource $source + * * @return Generator<\Sabre\VObject\Component\VCalendar> */ - private function importText(): Generator { - $importer = new TextImporter($this->source); + public function importText($source): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + $importer = new TextImporter($source); $structure = $importer->structure(); $sObjectPrefix = $importer::OBJECT_PREFIX; $sObjectSuffix = $importer::OBJECT_SUFFIX; @@ -113,10 +112,15 @@ class ImportService { /** * Generates object stream from a xml formatted source (xcal) * + * @param resource $source + * * @return Generator<\Sabre\VObject\Component\VCalendar> */ - private function importXml(): Generator { - $importer = new XmlImporter($this->source); + public function importXml($source): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + $importer = new XmlImporter($source); $structure = $importer->structure(); $sObjectPrefix = $importer::OBJECT_PREFIX; $sObjectSuffix = $importer::OBJECT_SUFFIX; @@ -155,11 +159,16 @@ class ImportService { /** * Generates object stream from a json formatted source (jcal) * + * @param resource $source + * * @return Generator<\Sabre\VObject\Component\VCalendar> */ - private function importJson(): Generator { + public function importJson($source): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } /** @var VCALENDAR $importer */ - $importer = Reader::readJson($this->source); + $importer = Reader::readJson($source); // calendar time zones $timezones = []; foreach ($importer->VTIMEZONE as $timezone) { @@ -212,17 +221,18 @@ class ImportService { * * @since 32.0.0 * + * @param resource $source * @param CalendarImportOptions $options * @param callable $generator: Generator<\Sabre\VObject\Component\VCalendar> * * @return array>> */ - public function importProcess(CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { + public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { $calendarId = $calendar->getKey(); $calendarUri = $calendar->getUri(); $principalUri = $calendar->getPrincipalUri(); $outcome = []; - foreach ($generator() as $vObject) { + foreach ($generator($source) as $vObject) { $components = $vObject->getBaseComponents(); // determine if the object has no base component types if (count($components) === 0) { diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php index 3981f7cdb60..559cff141b6 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php @@ -14,7 +14,6 @@ use OCP\Http\Client\IClientService; use OCP\Http\Client\LocalServerException; use OCP\IAppConfig; use Psr\Log\LoggerInterface; -use Sabre\VObject\Reader; class Connection { public function __construct( @@ -26,8 +25,10 @@ class Connection { /** * gets webcal feed from remote server + * + * @return array{data: resource, format: string}|null */ - public function queryWebcalFeed(array $subscription): ?string { + public function queryWebcalFeed(array $subscription): ?array { $subscriptionId = $subscription['id']; $url = $this->cleanURL($subscription['source']); if ($url === null) { @@ -54,6 +55,7 @@ class Connection { 'User-Agent' => $uaString, 'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml', ], + 'stream' => true, ]; $user = parse_url($subscription['source'], PHP_URL_USER); @@ -77,42 +79,22 @@ class Connection { return null; } - $body = $response->getBody(); - $contentType = $response->getHeader('Content-Type'); $contentType = explode(';', $contentType, 2)[0]; - switch ($contentType) { - case 'application/calendar+json': - try { - $jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); - return null; - } - return $jCalendar->serialize(); - - case 'application/calendar+xml': - try { - $xCalendar = Reader::readXML($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); - return null; - } - return $xCalendar->serialize(); - - case 'text/calendar': - default: - try { - $vCalendar = Reader::read($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); - return null; - } - return $vCalendar->serialize(); + + $format = match ($contentType) { + 'application/calendar+json' => 'jcal', + 'application/calendar+xml' => 'xcal', + default => 'ical', + }; + + // With 'stream' => true, getBody() returns the underlying stream resource + $stream = $response->getBody(); + if (!is_resource($stream)) { + return null; } + + return ['data' => $stream, 'format' => $format]; } /** diff --git a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php index a0981e6dec1..5e7c11a7164 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php @@ -9,18 +9,14 @@ declare(strict_types=1); namespace OCA\DAV\CalDAV\WebcalCaching; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Import\ImportService; use OCP\AppFramework\Utility\ITimeFactory; use Psr\Log\LoggerInterface; -use Sabre\DAV\Exception\BadRequest; -use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\PropPatch; use Sabre\VObject\Component; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; use Sabre\VObject\ParseException; -use Sabre\VObject\Reader; -use Sabre\VObject\Recur\NoInstancesException; -use Sabre\VObject\Splitter\ICalendar; use Sabre\VObject\UUIDUtil; use function count; @@ -36,20 +32,20 @@ class RefreshWebcalService { private LoggerInterface $logger, private Connection $connection, private ITimeFactory $time, + private ImportService $importService, ) { } public function refreshSubscription(string $principalUri, string $uri) { $subscription = $this->getSubscription($principalUri, $uri); - $mutations = []; if (!$subscription) { return; } // Check the refresh rate if there is any - if (!empty($subscription['{http://apple.com/ns/ical/}refreshrate'])) { - // add the refresh interval to the lastmodified timestamp - $refreshInterval = new \DateInterval($subscription['{http://apple.com/ns/ical/}refreshrate']); + if (!empty($subscription[self::REFRESH_RATE])) { + // add the refresh interval to the last modified timestamp + $refreshInterval = new \DateInterval($subscription[self::REFRESH_RATE]); $updateTime = $this->time->getDateTime(); $updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval); if ($updateTime->getTimestamp() > $this->time->getTime()) { @@ -57,109 +53,116 @@ class RefreshWebcalService { } } - - $webcalData = $this->connection->queryWebcalFeed($subscription); - if (!$webcalData) { + $result = $this->connection->queryWebcalFeed($subscription); + if (!$result) { return; } - $localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + $data = $result['data']; + $format = $result['format']; $stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1; $stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1; $stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1; try { - $splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING); - - while ($vObject = $splitter->getNext()) { - /** @var Component $vObject */ - $compName = null; - $uid = null; - - foreach ($vObject->getComponents() as $component) { - if ($component->name === 'VTIMEZONE') { - continue; - } - - $compName = $component->name; + $existingObjects = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, ['id', 'uid', 'etag', 'uri']); - if ($stripAlarms) { - unset($component->{'VALARM'}); - } - if ($stripAttachments) { - unset($component->{'ATTACH'}); - } - - $uid = $component->{ 'UID' }->getValue(); - } - - if ($stripTodos && $compName === 'VTODO') { - continue; - } + $generator = match ($format) { + 'xcal' => $this->importService->importXml(...), + 'jcal' => $this->importService->importJson(...), + default => $this->importService->importText(...) + }; - if (!isset($uid)) { - continue; - } + foreach ($generator($data) as $vObject) { + /** @var Component\VCalendar $vObject */ + $vBase = $vObject->getBaseComponent(); - try { - $denormalized = $this->calDavBackend->getDenormalizedData($vObject->serialize()); - } catch (InvalidDataException|Forbidden $ex) { - $this->logger->warning('Unable to denormalize calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + if (!$vBase->UID) { continue; } - // Find all identical sets and remove them from the update - if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) { - unset($localData[$uid]); + // Some calendar providers (e.g. Google, MS) use very long UIDs + if (strlen($vBase->UID->getValue()) > 512) { + $this->logger->warning('Skipping calendar object with overly long UID from subscription "{subscriptionId}"', [ + 'subscriptionId' => $subscription['id'], + 'uid' => $vBase->UID->getValue(), + ]); continue; } - $vObjectCopy = clone $vObject; - $identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]); - if ($identical) { - unset($localData[$uid]); + if ($stripTodos && $vBase->name === 'VTODO') { continue; } - // Find all modified sets and update them - if (isset($localData[$uid]) && $denormalized['etag'] !== $localData[$uid]['etag']) { - $this->calDavBackend->updateCalendarObject($subscription['id'], $localData[$uid]['uri'], $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); - unset($localData[$uid]); - continue; + if ($stripAlarms || $stripAttachments) { + foreach ($vObject->getComponents() as $component) { + if ($component->name === 'VTIMEZONE') { + continue; + } + if ($stripAlarms) { + $component->remove('VALARM'); + } + if ($stripAttachments) { + $component->remove('ATTACH'); + } + } } - // Only entirely new events get created here - try { - $objectUri = $this->getRandomCalendarObjectUri(); - $this->calDavBackend->createCalendarObject($subscription['id'], $objectUri, $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); - } catch (NoInstancesException|BadRequest $ex) { - $this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + $sObject = $vObject->serialize(); + $uid = $vBase->UID->getValue(); + $etag = md5($sObject); + + // No existing object with this UID, create it + if (!isset($existingObjects[$uid])) { + try { + $this->calDavBackend->createCalendarObject( + $subscription['id'], + UUIDUtil::getUUID() . '.ics', + $sObject, + CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION + ); + } catch (\Exception $ex) { + $this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', [ + 'exception' => $ex, + 'subscriptionId' => $subscription['id'], + 'source' => $subscription['source'], + ]); + } + } elseif ($existingObjects[$uid]['etag'] !== $etag) { + // Existing object with this UID but different etag, update it + $this->calDavBackend->updateCalendarObject( + $subscription['id'], + $existingObjects[$uid]['uri'], + $sObject, + CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION + ); + unset($existingObjects[$uid]); + } else { + // Existing object with same etag, just remove from tracking + unset($existingObjects[$uid]); } } - $ids = array_map(static function ($dataSet): int { - return (int)$dataSet['id']; - }, $localData); - $uris = array_map(static function ($dataSet): string { - return $dataSet['uri']; - }, $localData); - - if (!empty($ids) && !empty($uris)) { - // Clean up on aisle 5 - // The only events left over in the $localData array should be those that don't exist upstream - // All deleted VObjects from upstream are removed - $this->calDavBackend->purgeCachedEventsForSubscription($subscription['id'], $ids, $uris); + // Clean up objects that no longer exist in the remote feed + // The only events left over should be those not found upstream + if (!empty($existingObjects)) { + $ids = array_map('intval', array_column($existingObjects, 'id')); + $uris = array_column($existingObjects, 'uri'); + $this->calDavBackend->purgeCachedEventsForSubscription((int)$subscription['id'], $ids, $uris); } - $newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData); - if ($newRefreshRate) { - $mutations[self::REFRESH_RATE] = $newRefreshRate; + // Update refresh rate from the last processed object + if (isset($vObject)) { + $this->updateRefreshRate($subscription, $vObject); } - - $this->updateSubscription($subscription, $mutations); } catch (ParseException $ex) { $this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]); + } finally { + // Close the data stream to free resources + if (is_resource($data)) { + fclose($data); + } } } @@ -181,84 +184,34 @@ class RefreshWebcalService { return $subscriptions[0]; } - /** - * check if: - * - current subscription stores a refreshrate - * - the webcal feed suggests a refreshrate - * - return suggested refreshrate if user didn't set a custom one - * + * Update refresh rate from calendar object if: + * - current subscription does not store a refreshrate + * - the webcal feed suggests a valid refreshrate */ - private function checkWebcalDataForRefreshRate(array $subscription, string $webcalData): ?string { - // if there is no refreshrate stored in the database, check the webcal feed - // whether it suggests any refresh rate and store that in the database - if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) { - return null; + private function updateRefreshRate(array $subscription, Component\VCalendar $vCalendar): void { + // if there is already a refreshrate stored in the database, don't override it + if (!empty($subscription[self::REFRESH_RATE])) { + return; } - /** @var Component\VCalendar $vCalendar */ - $vCalendar = Reader::read($webcalData); - - $newRefreshRate = null; - if (isset($vCalendar->{'X-PUBLISHED-TTL'})) { - $newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue(); - } - if (isset($vCalendar->{'REFRESH-INTERVAL'})) { - $newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue(); - } + $refreshRate = $vCalendar->{'REFRESH-INTERVAL'}?->getValue() + ?? $vCalendar->{'X-PUBLISHED-TTL'}?->getValue(); - if (!$newRefreshRate) { - return null; + if ($refreshRate === null) { + return; } - // check if new refresh rate is even valid + // check if refresh rate is valid try { - DateTimeParser::parseDuration($newRefreshRate); - } catch (InvalidDataException $ex) { - return null; - } - - return $newRefreshRate; - } - - /** - * update subscription stored in database - * used to set: - * - refreshrate - * - source - * - * @param array $subscription - * @param array $mutations - */ - private function updateSubscription(array $subscription, array $mutations) { - if (empty($mutations)) { + DateTimeParser::parseDuration($refreshRate); + } catch (InvalidDataException) { return; } - $propPatch = new PropPatch($mutations); + $propPatch = new PropPatch([self::REFRESH_RATE => $refreshRate]); $this->calDavBackend->updateSubscription($subscription['id'], $propPatch); $propPatch->commit(); } - /** - * Returns a random uri for a calendar-object - * - * @return string - */ - public function getRandomCalendarObjectUri():string { - return UUIDUtil::getUUID() . '.ics'; - } - - private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool { - foreach ($vObject->getComponents() as $component) { - unset($component->{'DTSTAMP'}); - } - - $localVobject = Reader::read($calendarObject['calendardata']); - foreach ($localVobject->getComponents() as $component) { - unset($component->{'DTSTAMP'}); - } - - return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0; - } } diff --git a/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php b/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php index c29415ecef3..0c3f4f88fc5 100644 --- a/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php +++ b/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php @@ -89,12 +89,8 @@ class ConnectionTest extends TestCase { } - /** - * @param string $result - * @param string $contentType - */ #[\PHPUnit\Framework\Attributes\DataProvider('urlDataProvider')] - public function testConnection(string $url, string $result, string $contentType): void { + public function testConnection(string $url, string $contentType, string $expectedFormat): void { $client = $this->createMock(IClient::class); $response = $this->createMock(IResponse::class); $subscription = [ @@ -123,16 +119,76 @@ class ConnectionTest extends TestCase { ->with('https://foo.bar/bla2') ->willReturn($response); + $response->expects($this->once()) + ->method('getHeader') + ->with('Content-Type') + ->willReturn($contentType); + + // Create a stream resource to simulate streaming response + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'test calendar data'); + rewind($stream); + $response->expects($this->once()) ->method('getBody') + ->willReturn($stream); + + $output = $this->connection->queryWebcalFeed($subscription); + + $this->assertIsArray($output); + $this->assertArrayHasKey('data', $output); + $this->assertArrayHasKey('format', $output); + $this->assertIsResource($output['data']); + $this->assertEquals($expectedFormat, $output['format']); + + // Cleanup + if (is_resource($output['data'])) { + fclose($output['data']); + } + } + + public function testConnectionReturnsNullWhenBodyIsNotResource(): void { + $client = $this->createMock(IClient::class); + $response = $this->createMock(IResponse::class); + $subscription = [ + 'id' => 42, + 'uri' => 'sub123', + 'refreshreate' => 'P1H', + 'striptodos' => 1, + 'stripalarms' => 1, + 'stripattachments' => 1, + 'source' => 'https://foo.bar/bla2', + 'lastmodified' => 0, + ]; + + $this->clientService->expects($this->once()) + ->method('newClient') ->with() - ->willReturn($result); + ->willReturn($client); + + $this->config->expects($this->once()) + ->method('getValueString') + ->with('dav', 'webcalAllowLocalAccess', 'no') + ->willReturn('no'); + + $client->expects($this->once()) + ->method('get') + ->with('https://foo.bar/bla2') + ->willReturn($response); + $response->expects($this->once()) ->method('getHeader') ->with('Content-Type') - ->willReturn($contentType); + ->willReturn('text/calendar'); - $this->connection->queryWebcalFeed($subscription); + // Return a string instead of a resource + $response->expects($this->once()) + ->method('getBody') + ->willReturn('not a resource'); + + $output = $this->connection->queryWebcalFeed($subscription); + + $this->assertNull($output); } public static function runLocalURLDataProvider(): array { @@ -156,21 +212,9 @@ class ConnectionTest extends TestCase { public static function urlDataProvider(): array { return [ - [ - 'https://foo.bar/bla2', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", - 'text/calendar;charset=utf8', - ], - [ - 'https://foo.bar/bla2', - '["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]', - 'application/calendar+json', - ], - [ - 'https://foo.bar/bla2', - '-//Example Inc.//Example Client//EN2.02006-02-06T00:11:21ZUS/Eastern2006-01-04T14:00:00PT1HUS/Eastern2006-01-04T12:00:00Event #2 bis12345', - 'application/calendar+xml', - ], + ['https://foo.bar/bla2', 'text/calendar;charset=utf8', 'ical'], + ['https://foo.bar/bla2', 'application/calendar+json', 'jcal'], + ['https://foo.bar/bla2', 'application/calendar+xml', 'xcal'], ]; } } diff --git a/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php b/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php index d4f4b9e878f..3039b8be087 100644 --- a/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\DAV\Tests\unit\CalDAV\WebcalCaching; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Import\ImportService; use OCA\DAV\CalDAV\WebcalCaching\Connection; use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService; use OCP\AppFramework\Utility\ITimeFactory; @@ -23,7 +24,8 @@ class RefreshWebcalServiceTest extends TestCase { private CalDavBackend&MockObject $caldavBackend; private Connection&MockObject $connection; private LoggerInterface&MockObject $logger; - private ITimeFactory&MockObject $time; + private ImportService&MockObject $importService; + private ITimeFactory&MockObject $timeFactory; protected function setUp(): void { parent::setUp(); @@ -31,19 +33,32 @@ class RefreshWebcalServiceTest extends TestCase { $this->caldavBackend = $this->createMock(CalDavBackend::class); $this->connection = $this->createMock(Connection::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->time = $this->createMock(ITimeFactory::class); + $this->importService = $this->createMock(ImportService::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + // Default time factory behavior: current time is far in the future so refresh always happens + $this->timeFactory->method('getTime')->willReturn(PHP_INT_MAX); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime()); } - #[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')] - public function testRun(string $body, string $contentType, string $result): void { - $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) - ->getMock(); + /** + * Helper to create a resource stream from string content + */ + private function createStreamFromString(string $content) { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $content); + rewind($stream); + return $stream; + } - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); + #[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')] + public function testRun(string $body, string $format, string $result): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); $this->caldavBackend->expects(self::once()) ->method('getSubscriptionsForUser') @@ -71,26 +86,48 @@ class RefreshWebcalServiceTest extends TestCase { ], ]); + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => $format]); + + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); + + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + $this->caldavBackend->expects(self::once()) ->method('createCalendarObject') - ->with(42, 'uri-1.ics', $result, 1); + ->with( + '42', + self::matchesRegularExpression('/^[a-f0-9-]+\.ics$/'), + $result, + CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION + ); $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); } #[\PHPUnit\Framework\Attributes\DataProvider('identicalDataProvider')] - public function testRunIdentical(string $uid, array $calendarObject, string $body, string $contentType, string $result): void { - $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) - ->getMock(); - - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); + public function testRunIdentical(string $uid, array $calendarObject, string $body, string $format, string $result): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); $this->caldavBackend->expects(self::once()) ->method('getSubscriptionsForUser') @@ -118,78 +155,199 @@ class RefreshWebcalServiceTest extends TestCase { ], ]); + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => $format]); $this->caldavBackend->expects(self::once()) ->method('getLimitedCalendarObjects') ->willReturn($calendarObject); - $denormalised = [ - 'etag' => 100, - 'size' => strlen($calendarObject[$uid]['calendardata']), - 'uid' => 'sub456' - ]; + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); - $this->caldavBackend->expects(self::once()) - ->method('getDenormalizedData') - ->willReturn($denormalised); + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); $this->caldavBackend->expects(self::never()) ->method('createCalendarObject'); - $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub456'); + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); } - public function testRunJustUpdated(): void { - $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) - ->getMock(); + public function testSubscriptionNotFound(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/testuser') + ->willReturn([]); + + $this->connection->expects(self::never()) + ->method('queryWebcalFeed'); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + + public function testConnectionReturnsNull(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); $this->caldavBackend->expects(self::once()) ->method('getSubscriptionsForUser') ->with('principals/users/testuser') ->willReturn([ [ - 'id' => '99', - 'uri' => 'sub456', - RefreshWebcalService::REFRESH_RATE => 'P1D', + 'id' => '42', + 'uri' => 'sub123', RefreshWebcalService::STRIP_TODOS => '1', RefreshWebcalService::STRIP_ALARMS => '1', RefreshWebcalService::STRIP_ATTACHMENTS => '1', - 'source' => 'webcal://foo.bar/bla', - 'lastmodified' => time(), + 'source' => 'webcal://foo.bar/bla2', + 'lastmodified' => 0, ], + ]); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(null); + + $this->importService->expects(self::never()) + ->method('importText'); + + $this->caldavBackend->expects(self::never()) + ->method('createCalendarObject'); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + + public function testDeletedObjectsArePurged(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); + + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/testuser') + ->willReturn([ [ 'id' => '42', 'uri' => 'sub123', - RefreshWebcalService::REFRESH_RATE => 'PT1H', RefreshWebcalService::STRIP_TODOS => '1', RefreshWebcalService::STRIP_ALARMS => '1', RefreshWebcalService::STRIP_ATTACHMENTS => '1', 'source' => 'webcal://foo.bar/bla2', - 'lastmodified' => time(), + 'lastmodified' => 0, ], ]); - $timeMock = $this->createMock(\DateTime::class); - $this->time->expects(self::once()) - ->method('getDateTime') - ->willReturn($timeMock); - $timeMock->expects(self::once()) - ->method('getTimestamp') - ->willReturn(2101724667); - $this->time->expects(self::once()) - ->method('getTime') - ->willReturn(time()); - $this->connection->expects(self::never()) - ->method('queryWebcalFeed'); + $body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:new-event\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:New Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $stream = $this->createStreamFromString($body); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(['data' => $stream, 'format' => 'ical']); + + // Existing objects include one that won't be in the feed + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn([ + 'old-deleted-event' => [ + 'id' => 99, + 'uid' => 'old-deleted-event', + 'etag' => 'old-etag', + 'uri' => 'old-event.ics', + ], + ]); + + $vCalendar = VObject\Reader::read($body); + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + + $this->caldavBackend->expects(self::once()) + ->method('createCalendarObject'); + + $this->caldavBackend->expects(self::once()) + ->method('purgeCachedEventsForSubscription') + ->with(42, [99], ['old-event.ics']); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + + public function testLongUidIsSkipped(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); + + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/testuser') + ->willReturn([ + [ + 'id' => '42', + 'uri' => 'sub123', + RefreshWebcalService::STRIP_TODOS => '1', + RefreshWebcalService::STRIP_ALARMS => '1', + RefreshWebcalService::STRIP_ATTACHMENTS => '1', + 'source' => 'webcal://foo.bar/bla2', + 'lastmodified' => 0, + ], + ]); + + // Create a UID that is longer than 512 characters + $longUid = str_repeat('a', 513); + $body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:$longUid\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:Event with long UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $stream = $this->createStreamFromString($body); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(['data' => $stream, 'format' => 'ical']); + + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + $vCalendar = VObject\Reader::read($body); + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + + // Event with long UID should be skipped, so createCalendarObject should never be called $this->caldavBackend->expects(self::never()) ->method('createCalendarObject'); @@ -197,16 +355,12 @@ class RefreshWebcalServiceTest extends TestCase { } #[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')] - public function testRunCreateCalendarNoException(string $body, string $contentType, string $result): void { + public function testRunCreateCalendarNoException(string $body, string $format, string $result): void { $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription',]) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) + ->onlyMethods(['getSubscription']) + ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService]) ->getMock(); - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); - $refreshWebcalService ->method('getSubscription') ->willReturn([ @@ -220,13 +374,26 @@ class RefreshWebcalServiceTest extends TestCase { 'lastmodified' => 0, ]); + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => $format]); $this->caldavBackend->expects(self::once()) - ->method('createCalendarObject') - ->with(42, 'uri-1.ics', $result, 1); + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); + + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); $noInstanceException = new NoInstancesException("can't add calendar object"); $this->caldavBackend->expects(self::once()) @@ -241,16 +408,12 @@ class RefreshWebcalServiceTest extends TestCase { } #[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')] - public function testRunCreateCalendarBadRequest(string $body, string $contentType, string $result): void { + public function testRunCreateCalendarBadRequest(string $body, string $format, string $result): void { $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) + ->onlyMethods(['getSubscription']) + ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService]) ->getMock(); - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); - $refreshWebcalService ->method('getSubscription') ->willReturn([ @@ -264,13 +427,26 @@ class RefreshWebcalServiceTest extends TestCase { 'lastmodified' => 0, ]); + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => $format]); $this->caldavBackend->expects(self::once()) - ->method('createCalendarObject') - ->with(42, 'uri-1.ics', $result, 1); + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); + + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); $badRequestException = new BadRequest("can't add reach calendar url"); $this->caldavBackend->expects(self::once()) @@ -285,20 +461,22 @@ class RefreshWebcalServiceTest extends TestCase { } public static function identicalDataProvider(): array { + $icalBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $etag = md5($icalBody); + return [ [ '12345', [ '12345' => [ 'id' => 42, - 'etag' => 100, - 'uri' => 'sub456', - 'calendardata' => "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", + 'etag' => $etag, + 'uri' => 'sub456.ics', ], ], "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", - 'text/calendar;charset=utf8', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20180218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", + 'ical', + $icalBody, ], ]; } @@ -307,19 +485,9 @@ class RefreshWebcalServiceTest extends TestCase { return [ [ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", - 'text/calendar;charset=utf8', + 'ical', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", ], - [ - '["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]', - 'application/calendar+json', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nLAST-MODIFIED:20040110T032845Z\r\nTZID:US/Eastern\r\nBEGIN:DAYLIGHT\r\nDTSTART:20000404T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\r\nTZNAME:EDT\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nDTSTART:20001026T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=10\r\nTZNAME:EST\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060102T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" - ], - [ - '-//Example Inc.//Example Client//EN2.02006-02-06T00:11:21ZUS/Eastern2006-01-04T14:00:00PT1HUS/Eastern2006-01-04T12:00:00Event #2 bis12345', - 'application/calendar+xml', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060104T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2 bis\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" - ] ]; } } diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index a84d9e0283a..75beecd1639 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -433,11 +433,6 @@ - - - - - getKey()]]>