You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1014 lines
35 KiB
1014 lines
35 KiB
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
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;
|
|
use OCP\IL10N;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUser;
|
|
use OCP\Search\IFilter;
|
|
use OCP\Search\ISearchQuery;
|
|
use OCP\Search\SearchResult;
|
|
use OCP\Search\SearchResultEntry;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use Sabre\VObject\Reader;
|
|
use Test\TestCase;
|
|
|
|
class EventsSearchProviderTest extends TestCase {
|
|
private IAppManager&MockObject $appManager;
|
|
private IL10N&MockObject $l10n;
|
|
private IURLGenerator&MockObject $urlGenerator;
|
|
private CalDavBackend&MockObject $backend;
|
|
private EventsSearchProvider $provider;
|
|
|
|
// NO SUMMARY
|
|
private static string $vEvent0 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20161004T144433Z' . PHP_EOL
|
|
. 'UID:85560E76-1B0D-47E1-A735-21625767FCA4' . PHP_EOL
|
|
. 'DTEND;VALUE=DATE:20161008' . PHP_EOL
|
|
. 'TRANSP:TRANSPARENT' . PHP_EOL
|
|
. 'DTSTART;VALUE=DATE:20161005' . PHP_EOL
|
|
. 'DTSTAMP:20161004T144437Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. 'END:VEVENT' . PHP_EOL
|
|
. 'END:VCALENDAR';
|
|
|
|
// TIMED SAME DAY
|
|
private static string $vEvent1 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Tests//' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VTIMEZONE' . PHP_EOL
|
|
. 'TZID:Europe/Berlin' . PHP_EOL
|
|
. 'BEGIN:DAYLIGHT' . PHP_EOL
|
|
. 'TZOFFSETFROM:+0100' . PHP_EOL
|
|
. 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU' . PHP_EOL
|
|
. 'DTSTART:19810329T020000' . PHP_EOL
|
|
. 'TZNAME:GMT+2' . PHP_EOL
|
|
. 'TZOFFSETTO:+0200' . PHP_EOL
|
|
. 'END:DAYLIGHT' . PHP_EOL
|
|
. 'BEGIN:STANDARD' . PHP_EOL
|
|
. 'TZOFFSETFROM:+0200' . PHP_EOL
|
|
. 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU' . PHP_EOL
|
|
. 'DTSTART:19961027T030000' . PHP_EOL
|
|
. 'TZNAME:GMT+1' . PHP_EOL
|
|
. 'TZOFFSETTO:+0100' . PHP_EOL
|
|
. 'END:STANDARD' . PHP_EOL
|
|
. 'END:VTIMEZONE' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20160809T163629Z' . PHP_EOL
|
|
. 'UID:0AD16F58-01B3-463B-A215-FD09FC729A02' . PHP_EOL
|
|
. 'DTEND;TZID=Europe/Berlin:20160816T100000' . PHP_EOL
|
|
. 'TRANSP:OPAQUE' . PHP_EOL
|
|
. 'SUMMARY:Test Europe Berlin' . PHP_EOL
|
|
. 'DTSTART;TZID=Europe/Berlin:20160816T090000' . PHP_EOL
|
|
. 'DTSTAMP:20160809T163632Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. 'END:VEVENT' . PHP_EOL
|
|
. 'END:VCALENDAR';
|
|
|
|
// TIMED DIFFERENT DAY
|
|
private static string $vEvent2 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Tests//' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VTIMEZONE' . PHP_EOL
|
|
. 'TZID:Europe/Berlin' . PHP_EOL
|
|
. 'BEGIN:DAYLIGHT' . PHP_EOL
|
|
. 'TZOFFSETFROM:+0100' . PHP_EOL
|
|
. 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU' . PHP_EOL
|
|
. 'DTSTART:19810329T020000' . PHP_EOL
|
|
. 'TZNAME:GMT+2' . PHP_EOL
|
|
. 'TZOFFSETTO:+0200' . PHP_EOL
|
|
. 'END:DAYLIGHT' . PHP_EOL
|
|
. 'BEGIN:STANDARD' . PHP_EOL
|
|
. 'TZOFFSETFROM:+0200' . PHP_EOL
|
|
. 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU' . PHP_EOL
|
|
. 'DTSTART:19961027T030000' . PHP_EOL
|
|
. 'TZNAME:GMT+1' . PHP_EOL
|
|
. 'TZOFFSETTO:+0100' . PHP_EOL
|
|
. 'END:STANDARD' . PHP_EOL
|
|
. 'END:VTIMEZONE' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20160809T163629Z' . PHP_EOL
|
|
. 'UID:0AD16F58-01B3-463B-A215-FD09FC729A02' . PHP_EOL
|
|
. 'DTEND;TZID=Europe/Berlin:20160817T100000' . PHP_EOL
|
|
. 'TRANSP:OPAQUE' . PHP_EOL
|
|
. 'SUMMARY:Test Europe Berlin' . PHP_EOL
|
|
. 'DTSTART;TZID=Europe/Berlin:20160816T090000' . PHP_EOL
|
|
. 'DTSTAMP:20160809T163632Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. 'END:VEVENT' . PHP_EOL
|
|
. 'END:VCALENDAR';
|
|
|
|
// ALL-DAY ONE-DAY
|
|
private static string $vEvent3 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20161004T144433Z' . PHP_EOL
|
|
. 'UID:85560E76-1B0D-47E1-A735-21625767FCA4' . PHP_EOL
|
|
. 'DTEND;VALUE=DATE:20161006' . PHP_EOL
|
|
. 'TRANSP:TRANSPARENT' . PHP_EOL
|
|
. 'DTSTART;VALUE=DATE:20161005' . PHP_EOL
|
|
. 'DTSTAMP:20161004T144437Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. 'END:VEVENT' . PHP_EOL
|
|
. 'END:VCALENDAR';
|
|
|
|
// ALL-DAY MULTIPLE DAYS
|
|
private static string $vEvent4 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20161004T144433Z' . PHP_EOL
|
|
. 'UID:85560E76-1B0D-47E1-A735-21625767FCA4' . PHP_EOL
|
|
. 'DTEND;VALUE=DATE:20161008' . PHP_EOL
|
|
. 'TRANSP:TRANSPARENT' . PHP_EOL
|
|
. 'DTSTART;VALUE=DATE:20161005' . PHP_EOL
|
|
. 'DTSTAMP:20161004T144437Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. 'END:VEVENT' . PHP_EOL
|
|
. 'END:VCALENDAR';
|
|
|
|
// DURATION
|
|
private static string $vEvent5 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20161004T144433Z' . PHP_EOL
|
|
. 'UID:85560E76-1B0D-47E1-A735-21625767FCA4' . PHP_EOL
|
|
. 'DURATION:P5D' . PHP_EOL
|
|
. 'TRANSP:TRANSPARENT' . PHP_EOL
|
|
. 'DTSTART;VALUE=DATE:20161005' . PHP_EOL
|
|
. 'DTSTAMP:20161004T144437Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. 'END:VEVENT' . PHP_EOL
|
|
. 'END:VCALENDAR';
|
|
|
|
// NO DTEND - DATE
|
|
private static string $vEvent6 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20161004T144433Z' . PHP_EOL
|
|
. 'UID:85560E76-1B0D-47E1-A735-21625767FCA4' . PHP_EOL
|
|
. 'TRANSP:TRANSPARENT' . PHP_EOL
|
|
. 'DTSTART;VALUE=DATE:20161005' . PHP_EOL
|
|
. 'DTSTAMP:20161004T144437Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. 'END:VEVENT' . PHP_EOL
|
|
. 'END:VCALENDAR';
|
|
|
|
// NO DTEND - DATE-TIME
|
|
private static string $vEvent7 = 'BEGIN:VCALENDAR' . PHP_EOL
|
|
. 'VERSION:2.0' . PHP_EOL
|
|
. 'PRODID:-//Tests//' . PHP_EOL
|
|
. 'CALSCALE:GREGORIAN' . PHP_EOL
|
|
. 'BEGIN:VTIMEZONE' . PHP_EOL
|
|
. 'TZID:Europe/Berlin' . PHP_EOL
|
|
. 'BEGIN:DAYLIGHT' . PHP_EOL
|
|
. 'TZOFFSETFROM:+0100' . PHP_EOL
|
|
. 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU' . PHP_EOL
|
|
. 'DTSTART:19810329T020000' . PHP_EOL
|
|
. 'TZNAME:GMT+2' . PHP_EOL
|
|
. 'TZOFFSETTO:+0200' . PHP_EOL
|
|
. 'END:DAYLIGHT' . PHP_EOL
|
|
. 'BEGIN:STANDARD' . PHP_EOL
|
|
. 'TZOFFSETFROM:+0200' . PHP_EOL
|
|
. 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU' . PHP_EOL
|
|
. 'DTSTART:19961027T030000' . PHP_EOL
|
|
. 'TZNAME:GMT+1' . PHP_EOL
|
|
. 'TZOFFSETTO:+0100' . PHP_EOL
|
|
. 'END:STANDARD' . PHP_EOL
|
|
. 'END:VTIMEZONE' . PHP_EOL
|
|
. 'BEGIN:VEVENT' . PHP_EOL
|
|
. 'CREATED:20160809T163629Z' . PHP_EOL
|
|
. 'UID:0AD16F58-01B3-463B-A215-FD09FC729A02' . PHP_EOL
|
|
. 'TRANSP:OPAQUE' . PHP_EOL
|
|
. 'SUMMARY:Test Europe Berlin' . PHP_EOL
|
|
. 'DTSTART;TZID=Europe/Berlin:20160816T090000' . PHP_EOL
|
|
. 'DTSTAMP:20160809T163632Z' . PHP_EOL
|
|
. 'SEQUENCE:0' . PHP_EOL
|
|
. '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();
|
|
|
|
$this->appManager = $this->createMock(IAppManager::class);
|
|
$this->l10n = $this->createMock(IL10N::class);
|
|
$this->urlGenerator = $this->createMock(IURLGenerator::class);
|
|
$this->backend = $this->createMock(CalDavBackend::class);
|
|
|
|
$this->provider = new EventsSearchProvider(
|
|
$this->appManager,
|
|
$this->l10n,
|
|
$this->urlGenerator,
|
|
$this->backend
|
|
);
|
|
}
|
|
|
|
public function testGetId(): void {
|
|
$this->assertEquals('calendar', $this->provider->getId());
|
|
}
|
|
|
|
public function testGetName(): void {
|
|
$this->l10n->expects($this->exactly(1))
|
|
->method('t')
|
|
->with('Events')
|
|
->willReturnArgument(0);
|
|
|
|
$this->assertEquals('Events', $this->provider->getName());
|
|
}
|
|
|
|
public function testSearchAppDisabled(): void {
|
|
$user = $this->createMock(IUser::class);
|
|
$query = $this->createMock(ISearchQuery::class);
|
|
$this->appManager->expects($this->once())
|
|
->method('isEnabledForUser')
|
|
->with('calendar', $user)
|
|
->willReturn(false);
|
|
$this->l10n->expects($this->exactly(1))
|
|
->method('t')
|
|
->willReturnArgument(0);
|
|
$this->backend->expects($this->never())
|
|
->method('getCalendarsForUser');
|
|
$this->backend->expects($this->never())
|
|
->method('getSubscriptionsForUser');
|
|
$this->backend->expects($this->never())
|
|
->method('searchPrincipalUri');
|
|
|
|
$actual = $this->provider->search($user, $query);
|
|
$data = $actual->jsonSerialize();
|
|
$this->assertInstanceOf(SearchResult::class, $actual);
|
|
$this->assertEquals('Events', $data['name']);
|
|
$this->assertEmpty($data['entries']);
|
|
$this->assertFalse($data['isPaginated']);
|
|
$this->assertNull($data['cursor']);
|
|
}
|
|
|
|
public function testSearch(): void {
|
|
$user = $this->createMock(IUser::class);
|
|
$user->method('getUID')->willReturn('john.doe');
|
|
$query = $this->createMock(ISearchQuery::class);
|
|
$seachTermFilter = $this->createMock(IFilter::class);
|
|
$query->method('getFilter')->willReturnCallback(function ($name) use ($seachTermFilter) {
|
|
return match ($name) {
|
|
'term' => $seachTermFilter,
|
|
default => null,
|
|
};
|
|
});
|
|
$seachTermFilter->method('get')->willReturn('search term');
|
|
$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->backend->expects($this->once())
|
|
->method('getCalendarsForUser')
|
|
->with('principals/users/john.doe')
|
|
->willReturn([
|
|
[
|
|
'id' => 99,
|
|
'principaluri' => 'principals/users/john.doe',
|
|
'uri' => 'calendar-uri-99',
|
|
], [
|
|
'id' => 123,
|
|
'principaluri' => 'principals/users/john.doe',
|
|
'uri' => 'calendar-uri-123',
|
|
]
|
|
]);
|
|
$this->backend->expects($this->once())
|
|
->method('getSubscriptionsForUser')
|
|
->with('principals/users/john.doe')
|
|
->willReturn([
|
|
[
|
|
'id' => 1337,
|
|
'principaluri' => 'principals/users/john.doe',
|
|
'uri' => 'subscription-uri-1337',
|
|
]
|
|
]);
|
|
$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' => null, 'end' => null]])
|
|
->willReturn([
|
|
[
|
|
'calendarid' => 99,
|
|
'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR,
|
|
'uri' => 'event0.ics',
|
|
'calendardata' => self::$vEvent0,
|
|
],
|
|
[
|
|
'calendarid' => 123,
|
|
'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR,
|
|
'uri' => 'event1.ics',
|
|
'calendardata' => self::$vEvent1,
|
|
],
|
|
[
|
|
'calendarid' => 1337,
|
|
'calendartype' => CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION,
|
|
'uri' => 'event2.ics',
|
|
'calendardata' => self::$vEvent2,
|
|
]
|
|
]);
|
|
|
|
$provider = $this->getMockBuilder(EventsSearchProvider::class)
|
|
->setConstructorArgs([
|
|
$this->appManager,
|
|
$this->l10n,
|
|
$this->urlGenerator,
|
|
$this->backend,
|
|
])
|
|
->onlyMethods([
|
|
'getDeepLinkToCalendarApp',
|
|
'generateSubline',
|
|
])
|
|
->getMock();
|
|
|
|
$provider->expects($this->exactly(3))
|
|
->method('generateSubline')
|
|
->willReturn('subline');
|
|
$provider->expects($this->exactly(3))
|
|
->method('getDeepLinkToCalendarApp')
|
|
->willReturnMap([
|
|
['principals/users/john.doe', 'calendar-uri-99', 'event0.ics', 'deep-link-to-calendar'],
|
|
['principals/users/john.doe', 'calendar-uri-123', 'event1.ics', 'deep-link-to-calendar'],
|
|
['principals/users/john.doe', 'subscription-uri-1337', 'event2.ics', 'deep-link-to-calendar']
|
|
]);
|
|
|
|
$actual = $provider->search($user, $query);
|
|
$data = $actual->jsonSerialize();
|
|
$this->assertInstanceOf(SearchResult::class, $actual);
|
|
$this->assertEquals('Events', $data['name']);
|
|
$this->assertCount(3, $data['entries']);
|
|
$this->assertTrue($data['isPaginated']);
|
|
$this->assertEquals(23, $data['cursor']);
|
|
|
|
$result0 = $data['entries'][0];
|
|
$result0Data = $result0->jsonSerialize();
|
|
$result1 = $data['entries'][1];
|
|
$result1Data = $result1->jsonSerialize();
|
|
$result2 = $data['entries'][2];
|
|
$result2Data = $result2->jsonSerialize();
|
|
|
|
$this->assertInstanceOf(SearchResultEntry::class, $result0);
|
|
$this->assertEmpty($result0Data['thumbnailUrl']);
|
|
$this->assertEquals('Untitled event', $result0Data['title']);
|
|
$this->assertEquals('subline', $result0Data['subline']);
|
|
$this->assertEquals('deep-link-to-calendar', $result0Data['resourceUrl']);
|
|
$this->assertEquals('icon-calendar-dark', $result0Data['icon']);
|
|
$this->assertFalse($result0Data['rounded']);
|
|
|
|
$this->assertInstanceOf(SearchResultEntry::class, $result1);
|
|
$this->assertEmpty($result1Data['thumbnailUrl']);
|
|
$this->assertEquals('Test Europe Berlin', $result1Data['title']);
|
|
$this->assertEquals('subline', $result1Data['subline']);
|
|
$this->assertEquals('deep-link-to-calendar', $result1Data['resourceUrl']);
|
|
$this->assertEquals('icon-calendar-dark', $result1Data['icon']);
|
|
$this->assertFalse($result1Data['rounded']);
|
|
|
|
$this->assertInstanceOf(SearchResultEntry::class, $result2);
|
|
$this->assertEmpty($result2Data['thumbnailUrl']);
|
|
$this->assertEquals('Test Europe Berlin', $result2Data['title']);
|
|
$this->assertEquals('subline', $result2Data['subline']);
|
|
$this->assertEquals('deep-link-to-calendar', $result2Data['resourceUrl']);
|
|
$this->assertEquals('icon-calendar-dark', $result2Data['icon']);
|
|
$this->assertFalse($result2Data['rounded']);
|
|
}
|
|
|
|
public static function provideDeepLinkData(): array {
|
|
return [
|
|
['principals/users/john.doe', 'bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvam9obi5kb2UvZm9vL2Jhci5pY3M='],
|
|
['principals/users/John Doe', 'bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvSm9obiUyMERvZS9mb28vYmFyLmljcw=='],
|
|
['principals/users/john@doe', 'bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvam9obkBkb2UvZm9vL2Jhci5pY3M='],
|
|
];
|
|
}
|
|
|
|
#[DataProvider(methodName: 'provideDeepLinkData')]
|
|
public function testGetDeepLinkToCalendarApp(
|
|
string $principalUri,
|
|
string $expectedBase64DavUrl,
|
|
): void {
|
|
$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/$expectedBase64DavUrl")
|
|
->willReturn('absolute-url-to-route');
|
|
|
|
$actual = self::invokePrivate($this->provider, 'getDeepLinkToCalendarApp', [
|
|
$principalUri,
|
|
'foo',
|
|
'bar.ics',
|
|
]);
|
|
|
|
$this->assertEquals('absolute-url-to-route', $actual);
|
|
}
|
|
|
|
#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'generateSublineDataProvider')]
|
|
public function testGenerateSubline(string $ics, string $expectedSubline, array $calendarInfo = []): void {
|
|
$vCalendar = Reader::read($ics, Reader::OPTION_FORGIVING);
|
|
$eventComponent = $vCalendar->VEVENT;
|
|
|
|
$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');
|
|
});
|
|
|
|
$actual = self::invokePrivate($this->provider, 'generateSubline', [$eventComponent, $calendarInfo]);
|
|
$this->assertEquals($expectedSubline, $actual);
|
|
}
|
|
|
|
public static function generateSublineDataProvider(): array {
|
|
return [
|
|
[self::$vEvent1, '08-16 09:00 - 10:00', []],
|
|
[self::$vEvent2, '08-16 09:00 - 08-17 10:00', []],
|
|
[self::$vEvent3, '10-05', []],
|
|
[self::$vEvent4, '10-05 - 10-07', []],
|
|
[self::$vEvent5, '10-05 - 10-09', []],
|
|
[self::$vEvent6, '10-05', []],
|
|
[self::$vEvent7, '08-16 09:00 - 09:00', []],
|
|
[self::$vEvent1, '08-16 09:00 - 10:00 (My Calendar)', ['{DAV:}displayname' => 'My Calendar']],
|
|
[self::$vEvent3, '10-05 (My Calendar)', ['{DAV:}displayname' => 'My Calendar']],
|
|
[self::$vEvent2, '08-16 09:00 - 08-17 10:00 (My Calendar)', ['{DAV:}displayname' => 'My Calendar']],
|
|
[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);
|
|
}
|
|
}
|
|
|