Merge pull request #60766 from nextcloud/bug/noid/expand-unified-search

fix(caldav): Expand recurring events for principal calendar search
pull/62677/head
Daniel 3 weeks ago committed by GitHub
commit 4cf08882f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 69
      apps/dav/lib/CalDAV/CalDavBackend.php
  2. 28
      apps/dav/lib/Search/ACalendarSearchProvider.php
  3. 187
      apps/dav/lib/Search/EventsSearchProvider.php
  4. 30
      apps/dav/lib/Search/TasksSearchProvider.php
  5. 322
      apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
  6. 542
      apps/dav/tests/unit/Search/EventsSearchProviderTest.php
  7. 93
      apps/dav/tests/unit/Search/TasksSearchProviderTest.php

@ -2391,6 +2391,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
}
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 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
}
/**
* 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<array{uri: string, calendarid: int, calendartype: int, calendardata: string}>
*/
public function searchPrincipalUri(string $principalUri,
string $pattern,
@ -2516,6 +2531,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$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 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
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 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
->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);
}

@ -15,7 +15,7 @@ use OCP\IL10N;
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 @@ abstract class ACalendarSearchProvider implements IProvider {
/**
* 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;
}
}

@ -9,20 +9,24 @@ declare(strict_types=1);
namespace OCA\DAV\Search;
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;
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 Sabre\VObject\Recur\MaxInstancesExceededException;
use function array_push;
/**
* Class EventsSearchProvider
@ -100,7 +104,30 @@ class EventsSearchProvider extends ACalendarSearchProvider implements IFiltering
$subscriptionsById = $this->getSortedSubscriptions($principalUri);
/** @var string|null $term */
$term = $query->getFilter('term')?->get();
$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, cap the window at three years so an unbounded search
// does not expand a daily recurrence until the end of time.
if ($since instanceof DateTimeImmutable && $until === null) {
$until = $since->modify('+3 years');
}
if ($until instanceof DateTimeImmutable && $since === null) {
$since = $until->modify('-3 years');
}
/** @var array{start: DateTimeImmutable|null, end: DateTimeImmutable|null} $timeRange */
$timeRange = [
'start' => $since,
'end' => $until,
];
if ($term === null) {
$searchResults = [];
} else {
@ -113,10 +140,7 @@ class EventsSearchProvider extends ACalendarSearchProvider implements IFiltering
[
'limit' => $query->getLimit(),
'offset' => $query->getCursor(),
'timerange' => [
'start' => $query->getFilter('since')?->get(),
'end' => $query->getFilter('until')?->get(),
],
'timerange' => $timeRange,
]
);
}
@ -124,7 +148,7 @@ class EventsSearchProvider extends ACalendarSearchProvider implements IFiltering
$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 +157,140 @@ class EventsSearchProvider extends ACalendarSearchProvider implements IFiltering
[
'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 {
// Sabre refuses to generate more than Settings::$maxRecurrences (3500)
// instances, counted from DTSTART rather than from $since, so a
// fine-grained recurrence like FREQ=HOURLY exceeds it for any time
// range once it is old enough. Drop the row like an out-of-range one
// instead of failing the whole search request.
// 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|MaxInstancesExceededException $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,

@ -15,6 +15,10 @@ use OCP\Search\ISearchQuery;
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 @@ class TasksSearchProvider extends ACalendarSearchProvider {
'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 @@ class TasksSearchProvider extends ACalendarSearchProvider {
$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)
);
}

@ -1453,14 +1453,14 @@ EOD;
$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 @@ EOD;
// Clean up
$this->backend->deleteCalendar($calendars[0]['id'], true);
}
public function testSearchPrincipalWithTimeRange(): void {
$myPublic = <<<EOD
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org//mimedir.icalendar//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20131027T130000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
SUMMARY:My Test (public)
CLASS:PUBLIC
TRANSP:OPAQUE
STATUS:CONFIRMED
DTEND;TZID=Europe/Berlin:20131027T140000
LAST-MODIFIED:20160419T074202Z
DTSTAMP:20160419T074202Z
CREATED:20160419T074202Z
UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-1
END:VEVENT
END:VCALENDAR
EOD;
$myPrivate = <<<EOD
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org//mimedir.icalendar//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20131027T130000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
SUMMARY:My Test (private)
CLASS:PRIVATE
TRANSP:OPAQUE
STATUS:CONFIRMED
DTEND;TZID=Europe/Berlin:20131027T140000
LAST-MODIFIED:20160419T074202Z
DTSTAMP:20160419T074202Z
CREATED:20160419T074202Z
UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-2
END:VEVENT
END:VCALENDAR
EOD;
$myConfidential = <<<EOD
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org//mimedir.icalendar//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20131027T130000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
SUMMARY:My Test (confidential)
CLASS:CONFIDENTIAL
TRANSP:OPAQUE
STATUS:CONFIRMED
DTEND;TZID=Europe/Berlin:20131027T140000
LAST-MODIFIED:20160419T074202Z
DTSTAMP:20160419T074202Z
CREATED:20160419T074202Z
UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-3
END:VEVENT
END:VCALENDAR
EOD;
$sharerPublic = <<<EOD
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org//mimedir.icalendar//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20131027T130000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
SUMMARY:Sharer Test (public)
CLASS:PUBLIC
TRANSP:OPAQUE
STATUS:CONFIRMED
DTEND;TZID=Europe/Berlin:20131027T140000
LAST-MODIFIED:20160419T074202Z
DTSTAMP:20160419T074202Z
CREATED:20160419T074202Z
UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-4
END:VEVENT
END:VCALENDAR
EOD;
$sharerPrivate = <<<EOD
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org//mimedir.icalendar//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20131027T130000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
SUMMARY:Sharer Test (private)
CLASS:PRIVATE
TRANSP:OPAQUE
STATUS:CONFIRMED
DTEND;TZID=Europe/Berlin:20131027T140000
LAST-MODIFIED:20160419T074202Z
DTSTAMP:20160419T074202Z
CREATED:20160419T074202Z
UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-5
END:VEVENT
END:VCALENDAR
EOD;
$sharerConfidential = <<<EOD
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org//mimedir.icalendar//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20131027T130000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
SUMMARY:Sharer Test (confidential)
CLASS:CONFIDENTIAL
TRANSP:OPAQUE
STATUS:CONFIRMED
DTEND;TZID=Europe/Berlin:20131027T140000
LAST-MODIFIED:20160419T074202Z
DTSTAMP:20160419T074202Z
CREATED:20160419T074202Z
UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-6
END:VEVENT
END:VCALENDAR
EOD;
$l10n = $this->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);
}
}

@ -9,6 +9,8 @@ declare(strict_types=1);
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,56 @@ 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';
// Recurring event with a recurrence fine-grained enough to exceed Sabre's
// 3500 instance limit while iterating from DTSTART towards the requested
// range, so expanding it throws MaxInstancesExceededException.
private static string $vEvent10 = 'BEGIN:VCALENDAR' . PHP_EOL
. 'VERSION:2.0' . PHP_EOL
. 'PRODID:-//Tests//' . PHP_EOL
. 'CALSCALE:GREGORIAN' . PHP_EOL
. 'BEGIN:VEVENT' . PHP_EOL
. 'UID:hourly@example.com' . PHP_EOL
. 'DTSTAMP:20230101T080000Z' . PHP_EOL
. 'DTSTART;TZID=Europe/Berlin:20230101T090000' . PHP_EOL
. 'DTEND;TZID=Europe/Berlin:20230101T093000' . PHP_EOL
. 'RRULE:FREQ=HOURLY' . PHP_EOL
. 'SUMMARY:Hourly event' . PHP_EOL
. 'END:VEVENT' . PHP_EOL
. 'END:VCALENDAR';
protected function setUp(): void {
parent::setUp();
@ -469,4 +521,494 @@ class EventsSearchProviderTest extends TestCase {
[self::$vEvent1, '08-16 09:00 - 10:00', ['{DAV:}displayname' => '']],
];
}
public function testGetPrimaryComponentReturnsTheOnlyComponent(): void {
$ics = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:single-1
DTSTART;TZID=Europe/Berlin:20240601T090000
SUMMARY:Only
END:VEVENT
END:VCALENDAR
ICS;
$document = Reader::read($ics, Reader::OPTION_FORGIVING);
$actual = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:recur-1
RECURRENCE-ID;TZID=Europe/Berlin:20240602T090000
DTSTART;TZID=Europe/Berlin:20240602T090000
SUMMARY:Override
END:VEVENT
BEGIN:VEVENT
UID:recur-1
RRULE:FREQ=DAILY
DTSTART;TZID=Europe/Berlin:20240601T090000
SUMMARY:Master
END:VEVENT
END:VCALENDAR
ICS;
$document = Reader::read($ics, Reader::OPTION_FORGIVING);
$actual = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:recur-1
RECURRENCE-ID;TZID=Europe/Berlin:20240601T090000
DTSTART;TZID=Europe/Berlin:20240601T090000
SUMMARY:First
END:VEVENT
BEGIN:VEVENT
UID:recur-1
RECURRENCE-ID;TZID=Europe/Berlin:20240602T090000
DTSTART;TZID=Europe/Berlin:20240602T090000
SUMMARY:Second
END:VEVENT
END:VCALENDAR
ICS;
$document = Reader::read($ics, Reader::OPTION_FORGIVING);
$actual = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VTODO
UID:todo-1
SUMMARY:A task
END:VTODO
END:VCALENDAR
ICS;
$document = Reader::read($ics, Reader::OPTION_FORGIVING);
$actual = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:recur-1
DTSTART;TZID=Europe/Berlin:20240601T090000
DTEND;TZID=Europe/Berlin:20240601T100000
RRULE:FREQ=DAILY;COUNT=3
SUMMARY:Daily standup
END:VEVENT
END:VCALENDAR
ICS;
$vCalendar = Reader::read($ics, Reader::OPTION_FORGIVING);
$since = new \DateTimeImmutable('2000-01-01T00:00:00Z');
$until = new \DateTimeImmutable('2000-01-02T00:00:00Z');
$actual = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:recur-1
DTSTART;TZID=Europe/Berlin:20240601T090000
DTEND;TZID=Europe/Berlin:20240601T100000
RRULE:FREQ=DAILY;COUNT=3
SUMMARY:Daily standup
END:VEVENT
END:VCALENDAR
ICS;
$vCalendar = Reader::read($ics, Reader::OPTION_FORGIVING);
$since = new \DateTimeImmutable('2024-06-02T00:00:00Z');
$until = new \DateTimeImmutable('2024-06-03T00:00:00Z');
$expanded = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCARD
VERSION:4.0
UID:card-1
FN:John Doe
END:VCARD
ICS;
$since = new \DateTimeImmutable('2024-06-02T00:00:00Z');
$until = new \DateTimeImmutable('2024-06-03T00:00:00Z');
$actual = self::invokePrivate($this->provider, 'resolveComponent', [$ics, $since, $until]);
$this->assertNull($actual);
}
public function testResolveComponentReturnsNullWhenOutOfRange(): void {
// No occurrence within the requested window: the row is dropped.
$ics = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:recur-1
DTSTART;TZID=Europe/Berlin:20240601T090000
DTEND;TZID=Europe/Berlin:20240601T100000
RRULE:FREQ=DAILY;COUNT=3
SUMMARY:Daily standup
END:VEVENT
END:VCALENDAR
ICS;
$since = new \DateTimeImmutable('2000-01-01T00:00:00Z');
$until = new \DateTimeImmutable('2000-01-02T00:00:00Z');
$actual = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:recur-1
DTSTART;TZID=Europe/Berlin:20240601T090000
DTEND;TZID=Europe/Berlin:20240601T100000
RRULE:FREQ=DAILY;COUNT=3
SUMMARY:Daily standup
END:VEVENT
END:VCALENDAR
ICS;
$since = new \DateTimeImmutable('2024-06-02T00:00:00Z');
$until = new \DateTimeImmutable('2024-06-03T00:00:00Z');
$actual = self::invokePrivate($this->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tests//
BEGIN:VEVENT
UID:recur-1
DTSTART;TZID=Europe/Berlin:20240601T090000
DTEND;TZID=Europe/Berlin:20240601T100000
RRULE:FREQ=DAILY;COUNT=3
SUMMARY:Daily standup
END:VEVENT
END:VCALENDAR
ICS;
$actual = self::invokePrivate($this->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 testSearchSinceOnlyDefaultsUntilToThreeYearsLater(): void {
// No until: expanding recurrences still needs a concrete upper bound,
// so it defaults to three years after since.
$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('2029-05-15 00:00:00'),
]])
->willReturn([]);
$this->provider->search($user, $query);
}
public function testSearchUntilOnlyDefaultsSinceToThreeYearsEarlier(): void {
// No since: the lower bound defaults to three years before until 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('2023-06-14 00:00:00'),
'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
// 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);
}
public function testResolveComponentReturnsNullWhenRecurrenceLimitExceeded(): void {
// Sabre counts generated instances from DTSTART, not from the start of
// the requested range, so an hourly event older than 3500 hours blows
// the limit even for a one-day range. That must drop the row rather
// than fail the whole search request.
$since = new \DateTimeImmutable('2026-03-10T00:00:00Z');
$until = new \DateTimeImmutable('2026-03-11T00:00:00Z');
$actual = self::invokePrivate($this->provider, 'resolveComponent', [self::$vEvent10, $since, $until]);
$this->assertNull($actual);
}
}

@ -9,6 +9,7 @@ declare(strict_types=1);
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 @@ class TasksSearchProviderTest extends TestCase {
$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 @@ class TasksSearchProviderTest extends TestCase {
]);
$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 @@ class TasksSearchProviderTest extends TestCase {
$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')

Loading…
Cancel
Save