diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index a99e03042f582..408b957b1e378 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2473,13 +2473,20 @@ private function transformSearchProperty(Property $prop) { } /** + * Search calendar objects across a principal's calendars. + * + * This returns the stored calendar objects and does not expand recurring + * events. Callers that need the concrete occurrence for a requested time + * range must expand recurrences from `calendardata` themselves. + * * @param string $principalUri * @param string $pattern * @param array $componentTypes * @param array $searchProperties * @param array $searchParameters * @param array $options - * @return array + * + * @return list */ public function searchPrincipalUri(string $principalUri, string $pattern, @@ -2495,6 +2502,11 @@ public function searchPrincipalUri(string $principalUri, $calendarOr = []; $searchOr = []; + $start = null; + $end = null; + + // Todo: The retries when $hasLimit && $hasTimeRange from https://github.com/nextcloud/server/pull/45222 should also be applied here to the calendarObjectIdQuery + // Fetch calendars and subscription $calendars = $this->getCalendarsForUser($principalUri); $subscriptions = $this->getSubscriptionsForUser($principalUri); @@ -2573,19 +2585,21 @@ public function searchPrincipalUri(string $principalUri, if (isset($options['offset'])) { $calendarObjectIdQuery->setFirstResult($options['offset']); } - if (isset($options['timerange'])) { - if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt( - 'lastoccurence', - $calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()), - )); - } - if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt( - 'firstoccurence', - $calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()), - )); - } + if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $start */ + $start = $options['timerange']['start']; + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt( + 'lastoccurence', + $calendarObjectIdQuery->createNamedParameter($start->getTimestamp()), + )); + } + if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $end */ + $end = $options['timerange']['end']; + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt( + 'firstoccurence', + $calendarObjectIdQuery->createNamedParameter($end->getTimestamp()), + )); } $result = $calendarObjectIdQuery->executeQuery(); @@ -2600,17 +2614,14 @@ public function searchPrincipalUri(string $principalUri, ->from('calendarobjects') ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); - $result = $query->executeQuery(); - $calendarObjects = []; - while (($array = $result->fetchAssociative()) !== false) { - $array['calendarid'] = (int)$array['calendarid']; - $array['calendartype'] = (int)$array['calendartype']; - $array['calendardata'] = $this->readBlob($array['calendardata']); + $calendarObjects = $this->searchCalendarObjects($query, $start, $end); - $calendarObjects[] = $array; - } - $result->closeCursor(); - return $calendarObjects; + return array_map(function ($event) { + $event['calendarid'] = (int)$event['calendarid']; + $event['calendartype'] = (int)$event['calendartype']; + $event['calendardata'] = $this->readBlob($event['calendardata']); + return $event; + }, $calendarObjects); }, $this->db); } diff --git a/apps/dav/lib/Search/ACalendarSearchProvider.php b/apps/dav/lib/Search/ACalendarSearchProvider.php index 489b07aad57be..246b50bbe3fbd 100644 --- a/apps/dav/lib/Search/ACalendarSearchProvider.php +++ b/apps/dav/lib/Search/ACalendarSearchProvider.php @@ -15,6 +15,8 @@ use OCP\IURLGenerator; use OCP\Search\IProvider; use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\InvalidDataException; use Sabre\VObject\Reader; /** @@ -87,10 +89,41 @@ protected function getSortedSubscriptions(string $principalUri): array { * @param string $componentName * @return Component */ - protected function getPrimaryComponent(string $calendarData, string $componentName): Component { + protected function getPrimaryComponent(string $calendarData, string $componentName, \DateTimeInterface|null $since, \DateTimeInterface|null $until): Component { $vCalendar = Reader::read($calendarData, Reader::OPTION_FORGIVING); - $components = $vCalendar->select($componentName); + $originalTimeZone = null; + if ($vCalendar instanceof VCalendar && isset($since, $until)) { + // expand() rewrites every occurrence's DTSTART/DTEND to UTC, so remember + // the event's original timezone to display the occurrence in local time. + $baseComponent = $vCalendar->getBaseComponent($componentName); + if ($baseComponent !== null && isset($baseComponent->DTSTART) && $baseComponent->DTSTART->hasTime()) { + $originalTimeZone = $baseComponent->DTSTART->getDateTime()->getTimezone(); + } + + try { + $vCalendar = $vCalendar->expand($since, $until); + } catch (InvalidDataException $e) { + // fallback to the original event without expanding, leave its timezone untouched + $originalTimeZone = null; + } + } + + $component = $this->selectPrimaryComponent($vCalendar->select($componentName)); + + if ($originalTimeZone !== null) { + $this->applyTimeZone($component, $originalTimeZone); + } + + return $component; + } + + /** + * @param Component[] $components + */ + private function selectPrimaryComponent(array $components): Component { + // Expanded results: every instance has a RECURRENCE-ID; just take the first in-range occurrence. + // Stored objects: a recurrence-set is the master (no RECURRENCE-ID) plus override exceptions. if (count($components) === 1) { return $components[0]; } @@ -106,4 +139,18 @@ protected function getPrimaryComponent(string $calendarData, string $componentNa // In case of error, just fallback to the first element in the set return $components[0]; } + + /** + * Move the occurrence back into the event's original timezone after expand() + * has rewritten it to UTC, so the rendered time matches the user's local time. + */ + private function applyTimeZone(Component $component, \DateTimeZone $timeZone): void { + foreach (['DTSTART', 'DTEND'] as $name) { + if (isset($component->$name) && $component->$name->hasTime()) { + $component->$name->setDateTime( + $component->$name->getDateTime()->setTimezone($timeZone), + ); + } + } + } } diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php index 92c32f3de8cb4..4543719e9767f 100644 --- a/apps/dav/lib/Search/EventsSearchProvider.php +++ b/apps/dav/lib/Search/EventsSearchProvider.php @@ -9,6 +9,7 @@ namespace OCA\DAV\Search; +use DateTimeImmutable; use OCA\DAV\CalDAV\CalDavBackend; use OCP\IUser; use OCP\Search\IFilteringProvider; @@ -101,6 +102,20 @@ public function search( /** @var string|null $term */ $term = $query->getFilter('term')?->get(); + + $since = $query->getFilter('since')?->get(); + $until = $query->getFilter('until')?->get(); + + if ($since !== null && $until === null) { + $until = new DateTimeImmutable('now', new \DateTimeZone('Z')); + } + + /** @var array{start: DateTimeImmutable|null, end: DateTimeImmutable|null} $timeRange */ + $timeRange = [ + 'start' => $since, + 'end' => $until, + ]; + if ($term === null) { $searchResults = []; } else { @@ -113,10 +128,7 @@ public function search( [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - 'timerange' => [ - 'start' => $query->getFilter('since')?->get(), - 'end' => $query->getFilter('until')?->get(), - ], + 'timerange' => $timeRange, ] ); } @@ -133,10 +145,7 @@ public function search( [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - 'timerange' => [ - 'start' => $query->getFilter('since')?->get(), - 'end' => $query->getFilter('until')?->get(), - ], + 'timerange' => $timeRange, ], ); @@ -152,8 +161,8 @@ public function search( $searchResults[] = $attendeeResult; } } - $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById): SearchResultEntry { - $component = $this->getPrimaryComponent($eventRow['calendardata'], self::COMPONENT_TYPE); + $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById, $since, $until): SearchResultEntry { + $component = $this->getPrimaryComponent($eventRow['calendardata'], self::COMPONENT_TYPE, $since, $until); $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event')); if ($eventRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) { diff --git a/apps/dav/lib/Search/TasksSearchProvider.php b/apps/dav/lib/Search/TasksSearchProvider.php index 622e564d1364e..2a9d69b2d95f8 100644 --- a/apps/dav/lib/Search/TasksSearchProvider.php +++ b/apps/dav/lib/Search/TasksSearchProvider.php @@ -99,7 +99,7 @@ public function search( ] ); $formattedResults = \array_map(function (array $taskRow) use ($calendarsById, $subscriptionsById):SearchResultEntry { - $component = $this->getPrimaryComponent($taskRow['calendardata'], self::COMPONENT_TYPE); + $component = $this->getPrimaryComponent($taskRow['calendardata'], self::COMPONENT_TYPE, null, null); $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled task')); if ($taskRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) { diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index e0f507fbe358d..4b1687a9a42a9 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -1336,14 +1336,14 @@ public function testSearchPrincipal(): void { $this->assertCount(4, $mySearchResults); $this->assertCount(3, $sharerSearchResults); - $this->assertEquals($myPublic, $mySearchResults[0]['calendardata']); - $this->assertEquals($myPrivate, $mySearchResults[1]['calendardata']); - $this->assertEquals($myConfidential, $mySearchResults[2]['calendardata']); - $this->assertEquals($sharerPublic, $mySearchResults[3]['calendardata']); - - $this->assertEquals($sharerPublic, $sharerSearchResults[0]['calendardata']); - $this->assertEquals($sharerPrivate, $sharerSearchResults[1]['calendardata']); - $this->assertEquals($sharerConfidential, $sharerSearchResults[2]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($myPublic, $mySearchResults[0]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($myPrivate, $mySearchResults[1]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($myConfidential, $mySearchResults[2]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerPublic, $mySearchResults[3]['calendardata']); + + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerPublic, $sharerSearchResults[0]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerPrivate, $sharerSearchResults[1]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerConfidential, $sharerSearchResults[2]['calendardata']); } /** @@ -1988,4 +1988,310 @@ public function testDefaultAlarmProperties(): void { // Clean up $this->backend->deleteCalendar($calendars[0]['id'], true); } + + public function testSearchPrincipalWithTimeRange(): void { + $myPublic = <<createMock(IL10N::class); + $l10n + ->expects($this->any()) + ->method('t') + ->willReturnCallback(function ($text, $parameters = []) { + return vsprintf($text, $parameters); + }); + $config = $this->createMock(IConfig::class); + $this->userManager->expects($this->any()) + ->method('userExists') + ->willReturn(true); + $this->groupManager->expects($this->any()) + ->method('groupExists') + ->willReturn(true); + $this->principal->expects(self::atLeastOnce()) + ->method('findByUri') + ->willReturn(self::UNIT_TEST_USER); + + $me = self::UNIT_TEST_USER; + $sharer = self::UNIT_TEST_USER1; + $this->backend->createCalendar($me, 'calendar-uri-me', []); + $this->backend->createCalendar($sharer, 'calendar-uri-sharer', []); + $myCalendars = $this->backend->getCalendarsForUser($me); + $this->assertCount(1, $myCalendars); + $sharerCalendars = $this->backend->getCalendarsForUser($sharer); + $this->assertCount(1, $sharerCalendars); + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $sharerCalendar = new Calendar($this->backend, $sharerCalendars[0], $l10n, $config, $logger); + $this->backend->updateShares($sharerCalendar, [ + [ + 'href' => 'principal:' . $me, + 'readOnly' => false, + ], + ], []); + $this->assertCount(2, $this->backend->getCalendarsForUser($me)); + $this->backend->createCalendarObject($myCalendars[0]['id'], 'event0.ics', $myPublic); + $this->backend->createCalendarObject($myCalendars[0]['id'], 'event1.ics', $myPrivate); + $this->backend->createCalendarObject($myCalendars[0]['id'], 'event2.ics', $myConfidential); + $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event3.ics', $sharerPublic); + $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event4.ics', $sharerPrivate); + $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event5.ics', $sharerConfidential); + + $mySearchResults = $this->backend->searchPrincipalUri( + $me, + 'Test', + ['VEVENT'], + ['SUMMARY'], + [], + [ + 'timerange' => [ + 'start' => new DateTimeImmutable('2013-10-27 11:00:00', new DateTimeZone('UTC')), + 'end' => new DateTimeImmutable('2013-10-27 14:00:00', new DateTimeZone('UTC')), + ], + ] + ); + $sharerSearchResults = $this->backend->searchPrincipalUri($sharer, 'Test', ['VEVENT'], ['SUMMARY'], []); + + // Results with a time range are filtered but not expanded: the original + // DTSTART and RRULE are preserved (callers expand the occurrence themselves). + // Results without a time range are likewise not expanded. + $this->assertCount(4, $mySearchResults); + $this->assertCount(3, $sharerSearchResults); + $this->assertStringContainsString('SUMMARY:My Test (public)', $mySearchResults[0]['calendardata']); + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[0]['calendardata']); + $this->assertStringContainsString('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', $mySearchResults[0]['calendardata']); + $this->assertStringContainsString('SUMMARY:My Test (private)', $mySearchResults[1]['calendardata']); + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[1]['calendardata']); + $this->assertStringContainsString('SUMMARY:My Test (confidential)', $mySearchResults[2]['calendardata']); + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[2]['calendardata']); + $this->assertStringContainsString('SUMMARY:Sharer Test (public)', $mySearchResults[3]['calendardata']); + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[3]['calendardata']); + $this->assertStringContainsString('SUMMARY:Sharer Test (public)', $sharerSearchResults[0]['calendardata']); + $this->assertStringContainsString('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', $sharerSearchResults[0]['calendardata']); + $this->assertStringContainsString('SUMMARY:Sharer Test (private)', $sharerSearchResults[1]['calendardata']); + $this->assertStringContainsString('SUMMARY:Sharer Test (confidential)', $sharerSearchResults[2]['calendardata']); + } + + private function assertStringEqualsStringIgnoringLineEndingsWithTrim(string $expected, string $actual, string $message = ''): void { + $this->assertStringEqualsStringIgnoringLineEndings(trim($expected), trim($actual), $message); + } } diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index 561eb3e21b083..96bc2f5aa7996 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -9,6 +9,8 @@ namespace OCA\DAV\Tests\unit\Search; +use OC\Search\Filter\DateTimeFilter; +use OC\Search\Filter\StringFilter; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\Search\EventsSearchProvider; use OCP\App\IAppManager; @@ -211,6 +213,22 @@ class EventsSearchProviderTest extends TestCase { . 'END:VEVENT' . PHP_EOL . 'END:VCALENDAR'; + // Stored in a non-UTC timezone on purpose: expand() rewrites occurrences to UTC, + // so this exercises that the result is converted back to the event's local time. + private static string $vEvent8 = 'BEGIN:VCALENDAR' . PHP_EOL + . 'VERSION:2.0' . PHP_EOL + . 'PRODID:-//Tests//' . PHP_EOL + . 'CALSCALE:GREGORIAN' . PHP_EOL + . 'BEGIN:VEVENT' . PHP_EOL + . 'UID:recurring-yearly@example.com' . PHP_EOL + . 'DTSTAMP:20240601T080000Z' . PHP_EOL + . 'DTSTART;TZID=Europe/Berlin:20240601T090000' . PHP_EOL + . 'DTEND;TZID=Europe/Berlin:20240601T100000' . PHP_EOL + . 'RRULE:FREQ=YEARLY' . PHP_EOL + . 'SUMMARY:Recurring yearly event' . PHP_EOL + . 'END:VEVENT' . PHP_EOL + . 'END:VCALENDAR'; + protected function setUp(): void { parent::setUp(); @@ -469,4 +487,94 @@ public static function generateSublineDataProvider(): array { [self::$vEvent1, '08-16 09:00 - 10:00', ['{DAV:}displayname' => '']], ]; } + + public function testSearchSince(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('john.doe'); + $query = $this->createMock(ISearchQuery::class); + $query->method('getFilter')->willReturnCallback(function ($name) { + return match ($name) { + 'term' => new StringFilter('search term'), + 'since' => new DateTimeFilter('2026-05-15'), + 'until' => new DateTimeFilter('2026-06-14'), + default => null, + }; + }); + $query->method('getLimit')->willReturn(5); + $query->method('getCursor')->willReturn(20); + $this->appManager->expects($this->once()) + ->method('isEnabledForUser') + ->with('calendar', $user) + ->willReturn(true); + $this->l10n->method('t')->willReturnArgument(0); + $this->l10n->method('l') + ->willReturnCallback(static function (string $type, \DateTime $date, $_): string { + if ($type === 'time') { + return $date->format('H:i'); + } + return $date->format('m-d'); + }); + $this->backend->expects($this->once()) + ->method('getCalendarsForUser') + ->with('principals/users/john.doe') + ->willReturn([ + [ + 'id' => 99, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'calendar-uri-99', + '{DAV:}displayname' => 'My Calendar', + ] + ]); + $this->backend->expects($this->once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/john.doe') + ->willReturn([]); + $this->backend->expects($this->once()) + ->method('searchPrincipalUri') + ->with('principals/users/john.doe', 'search term', ['VEVENT'], + ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'], + ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']], + ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-05-15 00:00:00'), 'end' => new \DateTimeImmutable('2026-06-14 00:00:00')]]) + ->willReturn([ + [ + 'calendarid' => 99, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'recurring-yearly-event.ics', + 'calendardata' => self::$vEvent8, + ] + ]); + $this->urlGenerator->expects($this->once()) + ->method('linkTo') + ->with('', 'remote.php') + ->willReturn('link-to-remote.php'); + $this->urlGenerator->expects($this->once()) + ->method('linkToRoute') + ->with('calendar.view.index') + ->willReturn('link-to-route-calendar/'); + $this->urlGenerator->expects($this->once()) + ->method('getAbsoluteURL') + ->with('link-to-route-calendar/edit/bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvam9obi5kb2UvY2FsZW5kYXItdXJpLTk5L3JlY3VycmluZy15ZWFybHktZXZlbnQuaWNz') + ->willReturn('deep-link-to-calendar'); + + $actual = $this->provider->search($user, $query); + + $data = $actual->jsonSerialize(); + $this->assertInstanceOf(SearchResult::class, $actual); + $this->assertEquals('Events', $data['name']); + $this->assertCount(1, $data['entries']); + $this->assertTrue($data['isPaginated']); + $this->assertEquals(21, $data['cursor']); + $result0 = $data['entries'][0]; + $result0Data = $result0->jsonSerialize(); + $this->assertInstanceOf(SearchResultEntry::class, $result0); + $this->assertEmpty($result0Data['thumbnailUrl']); + $this->assertEquals('Recurring yearly event', $result0Data['title']); + // The occurrence is shown in the event's local time (Europe/Berlin, 09:00), + // not in the UTC time that expand() produces (07:00). + $this->assertEquals('06-01 09:00 - 10:00 (My Calendar)', $result0Data['subline']); + $this->assertEquals('deep-link-to-calendar', $result0Data['resourceUrl']); + $this->assertEquals('icon-calendar-dark', $result0Data['icon']); + $this->assertFalse($result0Data['rounded']); + $this->assertEquals('1780297200', $result0Data['attributes']['createdAt']); + } } diff --git a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php index 1424f8c4dbb7c..c76d6f84b175f 100644 --- a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php @@ -9,6 +9,7 @@ namespace OCA\DAV\Tests\unit\Search; +use OC\Search\Filter\StringFilter; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\Search\TasksSearchProvider; use OCP\App\IAppManager; @@ -152,7 +153,12 @@ public function testSearch(): void { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('john.doe'); $query = $this->createMock(ISearchQuery::class); - $query->method('getTerm')->willReturn('search term'); + $query->method('getFilter')->willReturnCallback(static function (string $name) { + return match ($name) { + 'term' => new StringFilter('search term'), + default => null, + }; + }); $query->method('getLimit')->willReturn(5); $query->method('getCursor')->willReturn(20); $this->appManager->expects($this->once()) @@ -187,7 +193,7 @@ public function testSearch(): void { ]); $this->backend->expects($this->once()) ->method('searchPrincipalUri') - ->with('principals/users/john.doe', '', ['VTODO'], + ->with('principals/users/john.doe', 'search term', ['VTODO'], ['SUMMARY', 'DESCRIPTION', 'CATEGORIES'], [], ['limit' => 5, 'offset' => 20, 'since' => null, 'until' => null])