From d66fe27fbfabe3af2fe4e2d8f4b8bb9728b381fd Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Fri, 17 Apr 2026 23:55:20 +0200 Subject: [PATCH] fix(caldav): Expand recurring events for principal calendar search Assisted-by: Claude:claude-sonnet-4-6 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 | 159 ++++++-- apps/dav/lib/Search/TasksSearchProvider.php | 18 +- .../tests/unit/CalDAV/CalDavBackendTest.php | 322 ++++++++++++++++- .../unit/Search/EventsSearchProviderTest.php | 340 ++++++++++++++++++ .../unit/Search/TasksSearchProviderTest.php | 10 +- 7 files changed, 855 insertions(+), 91 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 9254ba2ad102a..ba60189abb0ee 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2370,6 +2370,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' => [ @@ -2473,13 +2477,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, @@ -2495,6 +2510,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 +2593,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 +2622,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..d4f17302e516e 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\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; +use function array_values; /** * 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,125 @@ 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; + // Resolve each row to its in-range component (deduplicating events that + // matched both the term and attendee searches, keyed by calendarid-uri, and + // dropping anything that does not resolve to a usable in-range component) and + // format it. + $formattedResults = []; + foreach ($searchResults as $searchResult) { + $key = $searchResult['calendarid'] . '-' . $searchResult['uri']; + if (isset($formattedResults[$key])) { + continue; + } + $component = $this->resolveComponent($searchResult['calendardata'], $since, $until); + if ($component === null) { + continue; } - } - $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']]; + $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[$key] = $result; + } return SearchResult::paginated( $this->getName(), - $formattedResults, + array_values($formattedResults), $query->getCursor() + count($formattedResults) ); } + /** + * 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 { + $document = Reader::read($calendarData, Reader::OPTION_FORGIVING); + 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..75bed427b36d5 100644 --- a/apps/dav/lib/Search/TasksSearchProvider.php +++ b/apps/dav/lib/Search/TasksSearchProvider.php @@ -15,6 +15,8 @@ use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; /** * Class TasksSearchProvider @@ -98,8 +100,16 @@ 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) { + $vCalendar = Reader::read($taskRow['calendardata'], Reader::OPTION_FORGIVING); + 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,8 +120,8 @@ 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); + } return SearchResult::paginated( $this->getName(), 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..2297ec325e5d6 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,326 @@ 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']); + } } 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])