From c45ba8db66aaa404348a39e252375c2e5a436e89 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Fri, 17 Apr 2026 23:55:20 +0200 Subject: [PATCH 1/3] fix(caldav): Expand recurring events for principal calendar search Assisted-by: Claude:claude-sonnet-4-6 Assisted-by: Claude:claude-sonnet-5 Assisted-by: OpenCode:github-copilot/gpt-5.4 Signed-off-by: Daniel Kesselberg --- apps/dav/lib/CalDAV/CalDavBackend.php | 69 ++- .../lib/Search/ACalendarSearchProvider.php | 28 +- apps/dav/lib/Search/EventsSearchProvider.php | 169 +++++-- apps/dav/lib/Search/TasksSearchProvider.php | 30 +- .../tests/unit/CalDAV/CalDavBackendTest.php | 322 ++++++++++++- .../unit/Search/EventsSearchProviderTest.php | 443 ++++++++++++++++++ .../unit/Search/TasksSearchProviderTest.php | 93 +++- 7 files changed, 1062 insertions(+), 92 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 6b2d64b4d03e0..34617bba1b303 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2391,6 +2391,10 @@ private function searchCalendarObjects(IQueryBuilder $query, ?DateTimeInterface } try { + // The time-range filter is hardcoded to VEVENT: Sabre only + // expands VEVENT recurrences (EventIterator is VEVENT-only and + // VTodo::isInTimeRange ignores RRULE), so other component types + // would not be filtered correctly here. $isValid = $this->validateFilterForObject($row, [ 'name' => 'VCALENDAR', 'comp-filters' => [ @@ -2494,13 +2498,24 @@ 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. + * + * Note: when a `timerange` option is given, the precise filtering assumes + * VEVENT components (see searchCalendarObjects()). Passing other component + * types together with a `timerange` would drop all results. + * * @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, @@ -2516,6 +2531,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); @@ -2594,19 +2614,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(); @@ -2621,17 +2643,16 @@ 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_values(array_map(function ($event) { + return [ + 'uri' => (string)$event['uri'], + 'calendarid' => (int)$event['calendarid'], + 'calendartype' => (int)$event['calendartype'], + 'calendardata' => (string)$this->readBlob($event['calendardata']), + ]; + }, $calendarObjects)); }, $this->db); } diff --git a/apps/dav/lib/Search/ACalendarSearchProvider.php b/apps/dav/lib/Search/ACalendarSearchProvider.php index 489b07aad57be..70b95ca5ceb3d 100644 --- a/apps/dav/lib/Search/ACalendarSearchProvider.php +++ b/apps/dav/lib/Search/ACalendarSearchProvider.php @@ -15,7 +15,7 @@ use OCP\IURLGenerator; use OCP\Search\IProvider; use Sabre\VObject\Component; -use Sabre\VObject\Reader; +use Sabre\VObject\Component\VCalendar; /** * Class ACalendarSearchProvider @@ -76,34 +76,32 @@ protected function getSortedSubscriptions(string $principalUri): array { /** * Returns the primary VEvent / VJournal / VTodo component + * * If it's a component with recurrence-ids, it will return * the primary component * * TODO: It would be a nice enhancement to show recurrence-exceptions * as individual search-results. + * * For now we will just display the primary element of a recurrence-set. * - * @param string $calendarData + * Returns null when the calendar has no component of the requested type. + * + * @param VCalendar $vCalendar * @param string $componentName - * @return Component + * @return Component|null */ - protected function getPrimaryComponent(string $calendarData, string $componentName): Component { - $vCalendar = Reader::read($calendarData, Reader::OPTION_FORGIVING); - - $components = $vCalendar->select($componentName); - if (count($components) === 1) { - return $components[0]; - } - - // If it's a recurrence-set, take the primary element - foreach ($components as $component) { + protected function getPrimaryComponent(VCalendar $vCalendar, string $componentName): ?Component { + $first = null; + foreach ($vCalendar->select($componentName) as $component) { /** @var Component $component */ + // Prefer the recurrence-set master (no RECURRENCE-ID); otherwise the first element. + $first ??= $component; if (!$component->{'RECURRENCE-ID'}) { return $component; } } - // In case of error, just fallback to the first element in the set - return $components[0]; + return $first; } } diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php index 92c32f3de8cb4..66cabb8a3257b 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; @@ -16,13 +17,14 @@ use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; use Sabre\VObject\DateTimeParser; +use Sabre\VObject\InvalidDataException; +use Sabre\VObject\ParseException; use Sabre\VObject\Property; use Sabre\VObject\Property\ICalendar\DateTime; -use function array_combine; -use function array_fill; -use function array_key_exists; -use function array_map; +use Sabre\VObject\Reader; +use function array_push; /** * Class EventsSearchProvider @@ -101,6 +103,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 +129,7 @@ public function search( [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - 'timerange' => [ - 'start' => $query->getFilter('since')?->get(), - 'end' => $query->getFilter('until')?->get(), - ], + 'timerange' => $timeRange, ] ); } @@ -124,7 +137,7 @@ public function search( $person = $query->getFilter('person')?->get(); $personDisplayName = $person?->getDisplayName(); if ($personDisplayName !== null) { - $attendeeSearchResults = $this->backend->searchPrincipalUri( + array_push($searchResults, ...$this->backend->searchPrincipalUri( $principalUri, $personDisplayName, [self::COMPONENT_TYPE], @@ -133,55 +146,135 @@ public function search( [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - 'timerange' => [ - 'start' => $query->getFilter('since')?->get(), - 'end' => $query->getFilter('until')?->get(), - ], + 'timerange' => $timeRange, ], - ); + )); + } - $searchResultIndex = array_combine( - array_map(fn ($event) => $event['calendarid'] . '-' . $event['uri'], $searchResults), - array_fill(0, count($searchResults), null), - ); - foreach ($attendeeSearchResults as $attendeeResult) { - if (array_key_exists($attendeeResult['calendarid'] . '-' . $attendeeResult['uri'], $searchResultIndex)) { - // Duplicate - continue; - } - $searchResults[] = $attendeeResult; - } + // Deduplicate events that matched both the term and attendee searches, + // keyed by calendarid-uri. + $uniqueResults = []; + foreach ($searchResults as $searchResult) { + $key = $searchResult['calendarid'] . '-' . $searchResult['uri']; + $uniqueResults[$key] ??= $searchResult; } - $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById): SearchResultEntry { - $component = $this->getPrimaryComponent($eventRow['calendardata'], self::COMPONENT_TYPE); - $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event')); - if ($eventRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) { - $calendar = $calendarsById[$eventRow['calendarid']]; + // Resolve each row to its in-range component, dropping anything that + // does not resolve to a usable in-range component, and format it. + $formattedResults = []; + foreach ($uniqueResults as $searchResult) { + $component = $this->resolveComponent($searchResult['calendardata'], $since, $until); + if ($component === null) { + continue; + } + + $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event')); + if ($searchResult['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) { + $calendar = $calendarsById[$searchResult['calendarid']]; } else { - $calendar = $subscriptionsById[$eventRow['calendarid']]; + $calendar = $subscriptionsById[$searchResult['calendarid']]; } $subline = $this->generateSubline($component, $calendar); - $resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $eventRow['uri']); + $resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $searchResult['uri']); $result = new SearchResultEntry('', $title, $subline, $resourceUrl, 'icon-calendar-dark', false); $dtStart = $component->DTSTART; - if ($dtStart instanceof DateTime) { - $startDateTime = $dtStart->getDateTime()->format('U'); - $result->addAttribute('createdAt', $startDateTime); + $result->addAttribute('createdAt', $dtStart->getDateTime()->format('U')); } - return $result; - }, $searchResults); + $formattedResults[] = $result; + } + // Advance the cursor by the number of unique backend rows consumed, + // not by the number of formatted results, so a page where every row + // gets dropped (invalid data, no in-range occurrence) still moves the + // cursor forward instead of stalling pagination. return SearchResult::paginated( $this->getName(), $formattedResults, - $query->getCursor() + count($formattedResults) + $query->getCursor() + count($uniqueResults) ); } + /** + * Resolve the component to display for a result row. + * + * Parses the calendar data and, when a time range is requested, + * expands it to the in-range occurrence. Returns null to drop the row when the + * data is not a calendar or has no occurrence within since and until. + */ + private function resolveComponent(string $calendarData, ?\DateTimeInterface $since, ?\DateTimeInterface $until): ?Component { + try { + $document = Reader::read($calendarData, Reader::OPTION_FORGIVING); + } catch (ParseException|InvalidDataException) { + return null; + } + if (!$document instanceof VCalendar) { + return null; + } + + if ($since !== null && $until !== null) { + $document = $this->expandInRange($document, $since, $until); + if ($document === null) { + return null; + } + } + + return $this->getPrimaryComponent($document, self::COMPONENT_TYPE); + } + + /** + * Expand a recurring event into its occurrences within the requested + * [$since, $until] window, converted back from the UTC that expand() forces + * into the event's original timezone. + * + * Returns null when the event has no occurrence in range (recurrence gap) or + * cannot be expanded. + */ + private function expandInRange(VCalendar $vCalendar, \DateTimeInterface $since, \DateTimeInterface $until): ?VCalendar { + // expand() rewrites every occurrence's DTSTART/DTEND to UTC, so remember + // the event's original timezone to display the occurrence in local time. + $originalTimeZone = null; + $baseComponent = $vCalendar->getBaseComponent(self::COMPONENT_TYPE); + if ($baseComponent !== null && isset($baseComponent->DTSTART) && $baseComponent->DTSTART->hasTime()) { + $originalTimeZone = $baseComponent->DTSTART->getDateTime()->getTimezone(); + } + + try { + $expanded = $vCalendar->expand($since, $until); + } catch (InvalidDataException $e) { + return null; + } + + $occurrences = $expanded->select(self::COMPONENT_TYPE); + if ($occurrences === []) { + return null; + } + + if ($originalTimeZone !== null) { + foreach ($occurrences as $occurrence) { + $this->applyTimeZone($occurrence, $originalTimeZone); + } + } + + return $expanded; + } + + /** + * 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), + ); + } + } + } + protected function getDeepLinkToCalendarApp( string $principalUri, string $calendarUri, diff --git a/apps/dav/lib/Search/TasksSearchProvider.php b/apps/dav/lib/Search/TasksSearchProvider.php index 622e564d1364e..320cb82104db9 100644 --- a/apps/dav/lib/Search/TasksSearchProvider.php +++ b/apps/dav/lib/Search/TasksSearchProvider.php @@ -15,6 +15,10 @@ use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\InvalidDataException; +use Sabre\VObject\ParseException; +use Sabre\VObject\Reader; /** * Class TasksSearchProvider @@ -98,8 +102,20 @@ public function search( 'until' => $query->getFilter('until'), ] ); - $formattedResults = \array_map(function (array $taskRow) use ($calendarsById, $subscriptionsById):SearchResultEntry { - $component = $this->getPrimaryComponent($taskRow['calendardata'], self::COMPONENT_TYPE); + $formattedResults = []; + foreach ($searchResults as $taskRow) { + try { + $vCalendar = Reader::read($taskRow['calendardata'], Reader::OPTION_FORGIVING); + } catch (ParseException|InvalidDataException) { + continue; + } + if (!$vCalendar instanceof VCalendar) { + continue; + } + $component = $this->getPrimaryComponent($vCalendar, self::COMPONENT_TYPE); + if ($component === null) { + continue; + } $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled task')); if ($taskRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) { @@ -110,13 +126,17 @@ public function search( $subline = $this->generateSubline($component, $calendar); $resourceUrl = $this->getDeepLinkToTasksApp($calendar['uri'], $taskRow['uri']); - return new SearchResultEntry('', $title, $subline, $resourceUrl, 'icon-checkmark', false); - }, $searchResults); + $formattedResults[] = new SearchResultEntry('', $title, $subline, $resourceUrl, 'icon-checkmark', false); + } + // Advance the cursor by the number of backend rows consumed, not by + // the number of formatted results, so a page where every row gets + // dropped (invalid data) still moves the cursor forward instead of + // stalling pagination. return SearchResult::paginated( $this->getName(), $formattedResults, - $query->getCursor() + count($formattedResults) + $query->getCursor() + count($searchResults) ); } diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index aaa25e5e4b361..033b6bc5fd3d1 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -1453,14 +1453,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']); } /** @@ -2105,4 +2105,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..35ab144480749 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,39 @@ 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'; + + // Recurring event whose occurrences all fall before the ranges used in + // the tests below, so it resolves to no in-range occurrence and is + // dropped by resolveComponent(). + private static string $vEvent9 = 'BEGIN:VCALENDAR' . PHP_EOL + . 'VERSION:2.0' . PHP_EOL + . 'PRODID:-//Tests//' . PHP_EOL + . 'CALSCALE:GREGORIAN' . PHP_EOL + . 'BEGIN:VEVENT' . PHP_EOL + . 'UID:out-of-range@example.com' . PHP_EOL + . 'DTSTAMP:20000101T080000Z' . PHP_EOL + . 'DTSTART;TZID=Europe/Berlin:20000101T090000' . PHP_EOL + . 'DTEND;TZID=Europe/Berlin:20000101T100000' . PHP_EOL + . 'RRULE:FREQ=YEARLY;COUNT=3' . PHP_EOL + . 'SUMMARY:Out of range event' . PHP_EOL + . 'END:VEVENT' . PHP_EOL + . 'END:VCALENDAR'; + protected function setUp(): void { parent::setUp(); @@ -469,4 +504,412 @@ public static function generateSublineDataProvider(): array { [self::$vEvent1, '08-16 09:00 - 10:00', ['{DAV:}displayname' => '']], ]; } + + public function testGetPrimaryComponentReturnsTheOnlyComponent(): void { + $ics = <<provider, 'getPrimaryComponent', [$document, 'VEVENT']); + + $this->assertSame('Only', (string)$actual->SUMMARY); + } + + public function testGetPrimaryComponentReturnsTheRecurrenceSetMaster(): void { + // The override is intentionally listed before the master to prove the + // selection is driven by the missing RECURRENCE-ID, not document order. + $ics = <<provider, 'getPrimaryComponent', [$document, 'VEVENT']); + + $this->assertSame('Master', (string)$actual->SUMMARY); + } + + public function testGetPrimaryComponentFallsBackToFirstWhenAllAreOverrides(): void { + // Expanded occurrences all carry a RECURRENCE-ID, so the fallback returns + // the first element of the set. + $ics = <<provider, 'getPrimaryComponent', [$document, 'VEVENT']); + + $this->assertSame('First', (string)$actual->SUMMARY); + } + + public function testGetPrimaryComponentReturnsNullWhenComponentTypeIsAbsent(): void { + // A calendar without the requested component type must not crash. + $ics = <<provider, 'getPrimaryComponent', [$document, 'VEVENT']); + + $this->assertNull($actual); + } + + public function testExpandInRangeReturnsNullWhenOutOfRange(): void { + // A recurring event whose occurrences all fall outside the requested + // window: expand() yields nothing, so the caller falls back to the master. + $ics = <<provider, 'expandInRange', [$vCalendar, $since, $until]); + + $this->assertNull($actual); + } + + public function testExpandInRangeReturnsOccurrenceInOriginalTimeZone(): void { + // The in-range occurrence is converted back from the UTC that expand() + // forces to the event's original timezone. + $ics = <<provider, 'expandInRange', [$vCalendar, $since, $until]); + $occurrence = self::invokePrivate($this->provider, 'getPrimaryComponent', [$expanded, 'VEVENT']); + + $this->assertSame('Europe/Berlin', $occurrence->DTSTART->getDateTime()->getTimezone()->getName()); + $this->assertSame('2024-06-02 09:00', $occurrence->DTSTART->getDateTime()->format('Y-m-d H:i')); + } + + public function testResolveComponentReturnsNullForNonCalendar(): void { + // The backend hands us opaque data; a non-calendar object is dropped. + $ics = <<provider, 'resolveComponent', [$ics, $since, $until]); + + $this->assertNull($actual); + } + + public function testResolveComponentReturnsNullWhenOutOfRange(): void { + // No occurrence within the requested window: the row is dropped. + $ics = <<provider, 'resolveComponent', [$ics, $since, $until]); + + $this->assertNull($actual); + } + + public function testResolveComponentReturnsInRangeOccurrence(): void { + // With a time range, the in-range occurrence is returned in local time. + $ics = <<provider, 'resolveComponent', [$ics, $since, $until]); + + $this->assertSame('Europe/Berlin', $actual->DTSTART->getDateTime()->getTimezone()->getName()); + $this->assertSame('2024-06-02 09:00', $actual->DTSTART->getDateTime()->format('Y-m-d H:i')); + } + + public function testResolveComponentReturnsMasterWithoutTimeRange(): void { + // Without a time range nothing is expanded; the master is kept. + $ics = <<provider, 'resolveComponent', [$ics, null, null]); + + $this->assertSame('Daily standup', (string)$actual->SUMMARY); + $this->assertSame('2024-06-01 09:00', $actual->DTSTART->getDateTime()->format('Y-m-d H:i')); + } + + 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']); + } + + public function testSearchDropsInvalidRowsWithoutStallingCursor(): void { + // A row that resolves to no in-range occurrence must not prevent the + // cursor from advancing, or pagination would keep repeating the same + // page whenever every result on it gets dropped. + $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', + ] + ]); + $this->backend->expects($this->once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/john.doe') + ->willReturn([]); + $this->backend->expects($this->once()) + ->method('searchPrincipalUri') + ->willReturn([ + [ + 'calendarid' => 99, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'out-of-range-event.ics', + 'calendardata' => self::$vEvent9, + ], + [ + 'calendarid' => 99, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'recurring-yearly-event.ics', + 'calendardata' => self::$vEvent8, + ], + ]); + $this->urlGenerator->method('linkTo')->willReturn('link-to-remote.php'); + $this->urlGenerator->method('linkToRoute')->willReturn('link-to-route-calendar/'); + $this->urlGenerator->method('getAbsoluteURL')->willReturn('deep-link-to-calendar'); + + $actual = $this->provider->search($user, $query); + $data = $actual->jsonSerialize(); + + // Both backend rows are consumed even though one was dropped, so the + // cursor advances past both instead of stalling on the dropped row. + $this->assertCount(1, $data['entries']); + $this->assertEquals('Recurring yearly event', $data['entries'][0]->jsonSerialize()['title']); + $this->assertEquals(22, $data['cursor']); + } + + public function testResolveComponentReturnsNullForMalformedData(): void { + // Reader::read() can throw on data that isn't merely a non-calendar + // document (see testResolveComponentReturnsNullForNonCalendar) but is + // outright unparsable. That must be treated as a dropped row too, + // instead of failing the whole search request. + $since = new \DateTimeImmutable('2024-06-02T00:00:00Z'); + $until = new \DateTimeImmutable('2024-06-03T00:00:00Z'); + + $actual = self::invokePrivate($this->provider, 'resolveComponent', ['this is not calendar data', $since, $until]); + + $this->assertNull($actual); + } } diff --git a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php index 1424f8c4dbb7c..a8305c7b06bc0 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]) @@ -276,6 +282,89 @@ public function testSearch(): void { $this->assertFalse($result2Data['rounded']); } + public function testSearchDropsMalformedRowsWithoutStallingCursor(): void { + // A row whose calendardata can't even be parsed (Reader::read() throws) + // must be dropped instead of failing the whole request, and the cursor + // must still advance past it or pagination would repeat the same page + // whenever every result on it is malformed. + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('john.doe'); + $query = $this->createMock(ISearchQuery::class); + $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()) + ->method('isEnabledForUser') + ->with('tasks', $user) + ->willReturn(true); + $this->l10n->method('t')->willReturnArgument(0); + + $this->backend->expects($this->once()) + ->method('getCalendarsForUser') + ->with('principals/users/john.doe') + ->willReturn([ + [ + 'id' => 99, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'calendar-uri-99', + ] + ]); + $this->backend->expects($this->once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/john.doe') + ->willReturn([]); + $this->backend->expects($this->once()) + ->method('searchPrincipalUri') + ->willReturn([ + [ + 'calendarid' => 99, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'malformed.ics', + 'calendardata' => 'this is not calendar data', + ], + [ + 'calendarid' => 99, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'todo3.ics', + 'calendardata' => self::$vTodo3, + ], + ]); + + $provider = $this->getMockBuilder(TasksSearchProvider::class) + ->setConstructorArgs([ + $this->appManager, + $this->l10n, + $this->urlGenerator, + $this->backend, + ]) + ->onlyMethods([ + 'getDeepLinkToTasksApp', + 'generateSubline', + ]) + ->getMock(); + + $provider->expects($this->once()) + ->method('generateSubline') + ->willReturn('subline'); + $provider->expects($this->once()) + ->method('getDeepLinkToTasksApp') + ->willReturn('deep-link-to-tasks'); + + $actual = $provider->search($user, $query); + $data = $actual->jsonSerialize(); + + // Both backend rows are consumed even though one was dropped, so the + // cursor advances past both instead of stalling on the malformed row. + $this->assertCount(1, $data['entries']); + $this->assertEquals('Task title', $data['entries'][0]->jsonSerialize()['title']); + $this->assertEquals(22, $data['cursor']); + } + public function testGetDeepLinkToTasksApp(): void { $this->urlGenerator->expects($this->once()) ->method('linkToRoute') From 7afe6094a63a2095d6864b09eb81e179e80c5846 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 21 Jul 2026 19:32:14 +0200 Subject: [PATCH 2/3] fixup! fix(caldav): Expand recurring events for principal calendar search --- apps/dav/lib/Search/EventsSearchProvider.php | 23 ++++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php index 66cabb8a3257b..9ef2708803a37 100644 --- a/apps/dav/lib/Search/EventsSearchProvider.php +++ b/apps/dav/lib/Search/EventsSearchProvider.php @@ -12,6 +12,7 @@ use DateTimeImmutable; use OCA\DAV\CalDAV\CalDavBackend; use OCP\IUser; +use OCP\Search\IFilter; use OCP\Search\IFilteringProvider; use OCP\Search\ISearchQuery; use OCP\Search\SearchResult; @@ -102,13 +103,21 @@ public function search( $subscriptionsById = $this->getSortedSubscriptions($principalUri); /** @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')); + $term = $query->getFilter(IFilter::BUILTIN_TERM)?->get(); + + /** @var DateTimeImmutable|null $since */ + $since = $query->getFilter(IFilter::BUILTIN_SINCE)?->get(); + /** @var DateTimeImmutable|null $until */ + $until = $query->getFilter(IFilter::BUILTIN_UNTIL)?->get(); + + // Expanding recurrences needs two concrete bounds, unlike the plain + // mtime/timestamp comparisons other search providers use. When only + // one side is given, fill the other with an placeholder. + if ($since instanceof DateTimeImmutable && $until === null) { + $until = new DateTimeImmutable(CalDavBackend::MAX_DATE, $since->getTimezone()); + } + if ($until instanceof DateTimeImmutable && $since === null) { + $since = new DateTimeImmutable('@0'); } /** @var array{start: DateTimeImmutable|null, end: DateTimeImmutable|null} $timeRange */ From 9a42d6ca30963a1d21f255a7767e7445e641932c Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 21 Jul 2026 19:38:16 +0200 Subject: [PATCH 3/3] fixup! fix(caldav): Expand recurring events for principal calendar search --- .../unit/Search/EventsSearchProviderTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index 35ab144480749..db8b2155e750b 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -827,6 +827,76 @@ public function testSearchSince(): void { $this->assertEquals('1780297200', $result0Data['attributes']['createdAt']); } + public function testSearchSinceOnlyDefaultsUntilToMaxDate(): void { + // No until: expanding recurrences still needs a concrete upper bound, + // so it defaults to CalDavBackend::MAX_DATE (effectively unbounded) + // instead of guessing a caller's intent. + $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'), + default => null, + }; + }); + $query->method('getLimit')->willReturn(5); + $query->method('getCursor')->willReturn(20); + $this->appManager->method('isEnabledForUser')->willReturn(true); + $this->l10n->method('t')->willReturnArgument(0); + $this->backend->method('getCalendarsForUser')->willReturn([]); + $this->backend->method('getSubscriptionsForUser')->willReturn([]); + + $since = new \DateTimeImmutable('2026-05-15 00:00:00'); + $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' => $since, + 'end' => new \DateTimeImmutable(CalDavBackend::MAX_DATE, $since->getTimezone()), + ]]) + ->willReturn([]); + + $this->provider->search($user, $query); + } + + public function testSearchUntilOnlyDefaultsSinceToEpoch(): void { + // No since: the lower bound defaults to the Unix epoch (effectively + // unbounded into the past) for the same reason. + $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'), + 'until' => new DateTimeFilter('2026-06-14'), + default => null, + }; + }); + $query->method('getLimit')->willReturn(5); + $query->method('getCursor')->willReturn(20); + $this->appManager->method('isEnabledForUser')->willReturn(true); + $this->l10n->method('t')->willReturnArgument(0); + $this->backend->method('getCalendarsForUser')->willReturn([]); + $this->backend->method('getSubscriptionsForUser')->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('@0'), + 'end' => new \DateTimeImmutable('2026-06-14 00:00:00'), + ]]) + ->willReturn([]); + + $this->provider->search($user, $query); + } + public function testSearchDropsInvalidRowsWithoutStallingCursor(): void { // A row that resolves to no in-range occurrence must not prevent the // cursor from advancing, or pagination would keep repeating the same