]> source.dussan.org Git - nextcloud-server.git/commitdiff
Implement Contacts Backend for Unified Search 22011/head
authorGeorg Ehrke <developer@georgehrke.com>
Fri, 24 Jul 2020 12:57:52 +0000 (14:57 +0200)
committerJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Mon, 3 Aug 2020 14:29:01 +0000 (16:29 +0200)
Signed-off-by: Georg Ehrke <developer@georgehrke.com>
apps/dav/composer/composer/autoload_classmap.php
apps/dav/composer/composer/autoload_static.php
apps/dav/lib/AppInfo/Application.php
apps/dav/lib/CardDAV/CardDavBackend.php
apps/dav/lib/Search/ContactsSearchProvider.php [new file with mode: 0644]
apps/dav/lib/Search/ContactsSearchResultEntry.php [new file with mode: 0644]
apps/dav/tests/unit/Search/ContactsSearchProviderTest.php [new file with mode: 0644]

index b42750c25ea88d242b415fc9d89812937db967f6..bd63dee13b7d97be0925ff0bc4946cf094fcf6b7 100644 (file)
@@ -210,6 +210,8 @@ return array(
     'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningNode.php',
     'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php',
     'OCA\\DAV\\RootCollection' => $baseDir . '/../lib/RootCollection.php',
+    'OCA\\DAV\\Search\\ContactsSearchProvider' => $baseDir . '/../lib/Search/ContactsSearchProvider.php',
+    'OCA\\DAV\\Search\\ContactsSearchResultEntry' => $baseDir . '/../lib/Search/ContactsSearchResultEntry.php',
     'OCA\\DAV\\Server' => $baseDir . '/../lib/Server.php',
     'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php',
     'OCA\\DAV\\Storage\\PublicOwnerWrapper' => $baseDir . '/../lib/Storage/PublicOwnerWrapper.php',
index 2d579289f0516e64c4f9e720e3ec29ecadd311d8..a664c86f5fd8eaef27d3bccf8edc0213018bfd9d 100644 (file)
@@ -225,6 +225,8 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningNode.php',
         'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php',
         'OCA\\DAV\\RootCollection' => __DIR__ . '/..' . '/../lib/RootCollection.php',
+        'OCA\\DAV\\Search\\ContactsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/ContactsSearchProvider.php',
+        'OCA\\DAV\\Search\\ContactsSearchResultEntry' => __DIR__ . '/..' . '/../lib/Search/ContactsSearchResultEntry.php',
         'OCA\\DAV\\Server' => __DIR__ . '/..' . '/../lib/Server.php',
         'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php',
         'OCA\\DAV\\Storage\\PublicOwnerWrapper' => __DIR__ . '/..' . '/../lib/Storage/PublicOwnerWrapper.php',
index bf1e614633048f4592593aaa7c0c4d83f3ebe7f9..6f2f7b291534285f9e9350c0502cb2c43edb25a0 100644 (file)
@@ -54,6 +54,7 @@ use OCA\DAV\CardDAV\ContactsManager;
 use OCA\DAV\CardDAV\PhotoCache;
 use OCA\DAV\CardDAV\SyncService;
 use OCA\DAV\HookManager;
+use OCA\DAV\Search\ContactsSearchProvider;
 use OCP\AppFramework\App;
 use OCP\AppFramework\Bootstrap\IBootContext;
 use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -96,6 +97,11 @@ class Application extends App implements IBootstrap {
                 * Register capabilities
                 */
                $context->registerCapability(Capabilities::class);
+
+               /*
+                * Register Search Providers
+                */
+               $context->registerSearchProvider(ContactsSearchProvider::class);
        }
 
        public function boot(IBootContext $context): void {
index 9d602025c7a49771e75ba3a966d891e5eb198103..3b3474cdd01a33419001be9670182ad69c96a6a5 100644 (file)
@@ -951,7 +951,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
        }
 
        /**
-        * search contact
+        * Search contacts in a specific address-book
         *
         * @param int $addressBookId
         * @param string $pattern which should match within the $searchProperties
@@ -962,11 +962,55 @@ class CardDavBackend implements BackendInterface, SyncSupport {
         *      - 'offset' - Set the offset for the limited search results
         * @return array an array of contacts which are arrays of key-value-pairs
         */
-       public function search($addressBookId, $pattern, $searchProperties, $options = []) {
+       public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
+               return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
+       }
+
+       /**
+        * Search contacts in all address-books accessible by a user
+        *
+        * @param string $principalUri
+        * @param string $pattern
+        * @param array $searchProperties
+        * @param array $options
+        * @return array
+        */
+       public function searchPrincipalUri(string $principalUri,
+                                                                          string $pattern,
+                                                                          array $searchProperties,
+                                                                          array $options = []): array {
+               $addressBookIds = array_map(static function ($row):int {
+                       return (int) $row['id'];
+               }, $this->getAddressBooksForUser($principalUri));
+
+               return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
+       }
+
+       /**
+        * @param array $addressBookIds
+        * @param string $pattern
+        * @param array $searchProperties
+        * @param array $options
+        * @return array
+        */
+       private function searchByAddressBookIds(array $addressBookIds,
+                                                                                       string $pattern,
+                                                                                       array $searchProperties,
+                                                                                       array $options = []): array {
                $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
 
                $query2 = $this->db->getQueryBuilder();
-               $or = $query2->expr()->orX();
+
+               $addressBookOr =  $query2->expr()->orX();
+               foreach ($addressBookIds as $addressBookId) {
+                       $addressBookOr->add($query2->expr()->eq('cp.addressbookid', $query2->createNamedParameter($addressBookId)));
+               }
+
+               if ($addressBookOr->count() === 0) {
+                       return [];
+               }
+
+               $propertyOr = $query2->expr()->orX();
                foreach ($searchProperties as $property) {
                        if ($escapePattern) {
                                if ($property === 'EMAIL' && strpos($pattern, ' ') !== false) {
@@ -980,17 +1024,17 @@ class CardDavBackend implements BackendInterface, SyncSupport {
                                }
                        }
 
-                       $or->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property)));
+                       $propertyOr->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property)));
                }
 
-               if ($or->count() === 0) {
+               if ($propertyOr->count() === 0) {
                        return [];
                }
 
                $query2->selectDistinct('cp.cardid')
                        ->from($this->dbCardsPropertiesTable, 'cp')
-                       ->andWhere($query2->expr()->eq('cp.addressbookid', $query2->createNamedParameter($addressBookId)))
-                       ->andWhere($or);
+                       ->andWhere($addressBookOr)
+                       ->andWhere($propertyOr);
 
                // No need for like when the pattern is empty
                if ('' !== $pattern) {
@@ -1016,7 +1060,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
                }, $matches);
 
                $query = $this->db->getQueryBuilder();
-               $query->select('c.carddata', 'c.uri')
+               $query->select('c.addressbookid', 'c.carddata', 'c.uri')
                        ->from($this->dbCardsTable, 'c')
                        ->where($query->expr()->in('c.id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY)));
 
@@ -1026,6 +1070,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
                $result->closeCursor();
 
                return array_map(function ($array) {
+                       $array['addressbookid'] = (int) $array['addressbookid'];
                        $modified = false;
                        $array['carddata'] = $this->readBlob($array['carddata'], $modified);
                        if ($modified) {
diff --git a/apps/dav/lib/Search/ContactsSearchProvider.php b/apps/dav/lib/Search/ContactsSearchProvider.php
new file mode 100644 (file)
index 0000000..656b484
--- /dev/null
@@ -0,0 +1,189 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Search;
+
+use OCA\DAV\CardDAV\CardDavBackend;
+use OCP\App\IAppManager;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\Search\IProvider;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use Sabre\VObject\Component\VCard;
+use Sabre\VObject\Reader;
+
+class ContactsSearchProvider implements IProvider {
+
+       /** @var IAppManager */
+       private $appManager;
+
+       /** @var IL10N */
+       private $l10n;
+
+       /** @var IURLGenerator */
+       private $urlGenerator;
+
+       /** @var CardDavBackend */
+       private $backend;
+
+       /**
+        * @var string[]
+        */
+       private static $searchProperties = [
+               'N',
+               'FN',
+               'NICKNAME',
+               'EMAIL',
+               'ADR',
+       ];
+
+       /**
+        * ContactsSearchProvider constructor.
+        *
+        * @param IAppManager $appManager
+        * @param IL10N $l10n
+        * @param IURLGenerator $urlGenerator
+        * @param CardDavBackend $backend
+        */
+       public function __construct(IAppManager $appManager,
+                                                               IL10N $l10n,
+                                                               IURLGenerator $urlGenerator,
+                                                               CardDavBackend $backend) {
+               $this->appManager = $appManager;
+               $this->l10n = $l10n;
+               $this->urlGenerator = $urlGenerator;
+               $this->backend = $backend;
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function getId(): string {
+               return 'contacts-dav';
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function getName(): string {
+               return $this->l10n->t('Contacts');
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function search(IUser $user, ISearchQuery $query): SearchResult {
+               if (!$this->appManager->isEnabledForUser('contacts', $user)) {
+                       return SearchResult::complete($this->getName(), []);
+               }
+
+               $principalUri = 'principals/users/' . $user->getUID();
+               $addressBooks = $this->backend->getAddressBooksForUser($principalUri);
+               $addressBooksById = [];
+               foreach ($addressBooks as $addressBook) {
+                       $addressBooksById[(int) $addressBook['id']] = $addressBook;
+               }
+
+               $searchResults = $this->backend->searchPrincipalUri(
+                       $principalUri,
+                       $query->getTerm(),
+                       self::$searchProperties,
+                       [
+                               'limit' => $query->getLimit(),
+                               'offset' => $query->getCursor(),
+                       ]
+               );
+               $formattedResults = \array_map(function (array $contactRow) use ($addressBooksById):ContactsSearchResultEntry {
+                       $addressBook = $addressBooksById[$contactRow['addressbookid']];
+
+                       /** @var VCard $vCard */
+                       $vCard = Reader::read($contactRow['carddata']);
+                       $thumbnailUrl = '';
+                       if ($vCard->PHOTO) {
+                               $thumbnailUrl = $this->getDavUrlForContact($addressBook['principaluri'], $addressBook['uri'], $contactRow['uri']) . '?photo';
+                       }
+
+                       $title = (string)$vCard->FN;
+                       $subline = $this->generateSubline($vCard);
+                       $resourceUrl = $this->getDeepLinkToContactsApp($addressBook['uri'], (string) $vCard->UID);
+
+                       return new ContactsSearchResultEntry($thumbnailUrl, $title, $subline, $resourceUrl, 'icon-contacts-dark', true);
+               }, $searchResults);
+
+               return SearchResult::paginated(
+                       $this->getName(),
+                       $formattedResults,
+                       $query->getCursor() + count($formattedResults)
+               );
+       }
+
+       /**
+        * @param string $principalUri
+        * @param string $addressBookUri
+        * @param string $contactsUri
+        * @return string
+        */
+       protected function getDavUrlForContact(string $principalUri,
+                                                                                  string $addressBookUri,
+                                                                                  string $contactsUri): string {
+               [, $principalType, $principalId] = explode('/', $principalUri, 3);
+
+               return $this->urlGenerator->getAbsoluteURL(
+                       $this->urlGenerator->linkTo('', 'remote.php') . '/dav/addressbooks/'
+                               . $principalType . '/'
+                               . $principalId . '/'
+                               . $addressBookUri . '/'
+                               . $contactsUri
+               );
+       }
+
+       /**
+        * @param string $addressBookUri
+        * @param string $contactUid
+        * @return string
+        */
+       protected function getDeepLinkToContactsApp(string $addressBookUri,
+                                                                                               string $contactUid): string {
+               return $this->urlGenerator->getAbsoluteURL(
+                       $this->urlGenerator->linkToRoute('contacts.contacts.direct', [
+                               'contact' => $contactUid . '~' . $addressBookUri
+                       ])
+               );
+       }
+
+       /**
+        * @param VCard $vCard
+        * @return string
+        */
+       protected function generateSubline(VCard $vCard): string {
+               $emailAddresses = $vCard->select('EMAIL');
+               if (!is_array($emailAddresses) || empty($emailAddresses)) {
+                       return '';
+               }
+
+               return (string)$emailAddresses[0];
+       }
+}
diff --git a/apps/dav/lib/Search/ContactsSearchResultEntry.php b/apps/dav/lib/Search/ContactsSearchResultEntry.php
new file mode 100644 (file)
index 0000000..698fc1b
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Search;
+
+use OCP\Search\ASearchResultEntry;
+
+class ContactsSearchResultEntry extends ASearchResultEntry {
+}
diff --git a/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php b/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php
new file mode 100644 (file)
index 0000000..d89d8dd
--- /dev/null
@@ -0,0 +1,275 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Tests\unit;
+
+use OCA\DAV\CardDAV\CardDavBackend;
+use OCA\DAV\Search\ContactsSearchProvider;
+use OCA\DAV\Search\ContactsSearchResultEntry;
+use OCP\App\IAppManager;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use Sabre\VObject\Reader;
+use Test\TestCase;
+
+class ContactsSearchProviderTest extends TestCase {
+
+       /** @var IAppManager|\PHPUnit\Framework\MockObject\MockObject */
+       private $appManager;
+
+       /** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
+       private $l10n;
+
+       /** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */
+       private $urlGenerator;
+
+       /** @var CardDavBackend|\PHPUnit\Framework\MockObject\MockObject */
+       private $backend;
+
+       /** @var ContactsSearchProvider */
+       private $provider;
+
+       private $vcardTest0 = 'BEGIN:VCARD'.PHP_EOL.
+               'VERSION:3.0'.PHP_EOL.
+               'PRODID:-//Sabre//Sabre VObject 4.1.2//EN'.PHP_EOL.
+               'UID:Test'.PHP_EOL.
+               'FN:FN of Test'.PHP_EOL.
+               'N:Test;;;;'.PHP_EOL.
+               'EMAIL:forrestgump@example.com'.PHP_EOL.
+               'END:VCARD';
+
+       private $vcardTest1 = 'BEGIN:VCARD'.PHP_EOL.
+               'VERSION:3.0'.PHP_EOL.
+               'PRODID:-//Sabre//Sabre VObject 4.1.2//EN'.PHP_EOL.
+               'PHOTO;ENCODING=b;TYPE=image/jpeg:'.PHP_EOL.
+               'UID:Test2'.PHP_EOL.
+               'FN:FN of Test2'.PHP_EOL.
+               'N:Test2;;;;'.PHP_EOL.
+               'END:VCARD';
+
+       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(CardDavBackend::class);
+
+               $this->provider = new ContactsSearchProvider(
+                       $this->appManager,
+                       $this->l10n,
+                       $this->urlGenerator,
+                       $this->backend
+               );
+       }
+
+       public function testGetId(): void {
+               $this->assertEquals('contacts-dav', $this->provider->getId());
+       }
+
+       public function testGetName(): void {
+               $this->l10n->expects($this->exactly(1))
+                       ->method('t')
+                       ->with('Contacts')
+                       ->willReturnArgument(0);
+
+               $this->assertEquals('Contacts', $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('contacts', $user)
+                       ->willReturn(false);
+               $this->l10n->expects($this->exactly(1))
+                       ->method('t')
+                       ->with('Contacts')
+                       ->willReturnArgument(0);
+               $this->backend->expects($this->never())
+                       ->method('getAddressBooksForUser');
+               $this->backend->expects($this->never())
+                       ->method('searchPrincipalUri');
+
+               $actual = $this->provider->search($user, $query);
+               $data = $actual->jsonSerialize();
+               $this->assertInstanceOf(SearchResult::class, $actual);
+               $this->assertEquals('Contacts', $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);
+               $query->method('getTerm')->willReturn('search term');
+               $query->method('getLimit')->willReturn(5);
+               $query->method('getCursor')->willReturn(20);
+               $this->appManager->expects($this->once())
+                       ->method('isEnabledForUser')
+                       ->with('contacts', $user)
+                       ->willReturn(true);
+               $this->l10n->expects($this->exactly(1))
+                       ->method('t')
+                       ->with('Contacts')
+                       ->willReturnArgument(0);
+
+               $this->backend->expects($this->once())
+                       ->method('getAddressBooksForUser')
+                       ->with('principals/users/john.doe')
+                       ->willReturn([
+                               [
+                                       'id' => 99,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'addressbook-uri-99',
+                               ], [
+                                       'id' => 123,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'addressbook-uri-123',
+                               ]
+                       ]);
+               $this->backend->expects($this->once())
+                       ->method('searchPrincipalUri')
+                       ->with('principals/users/john.doe', 'search term',
+                               ['N', 'FN', 'NICKNAME', 'EMAIL', 'ADR'],
+                               ['limit' => 5, 'offset' => 20])
+                       ->willReturn([
+                               [
+                                       'addressbookid' => 99,
+                                       'uri' => 'vcard0.vcf',
+                                       'carddata' => $this->vcardTest0,
+                               ],
+                               [
+                                       'addressbookid' => 123,
+                                       'uri' => 'vcard1.vcf',
+                                       'carddata' => $this->vcardTest1,
+                               ],
+                       ]);
+
+               $provider = $this->getMockBuilder(ContactsSearchProvider::class)
+                       ->setConstructorArgs([
+                               $this->appManager,
+                               $this->l10n,
+                               $this->urlGenerator,
+                               $this->backend,
+                       ])
+                       ->setMethods([
+                               'getDavUrlForContact',
+                               'getDeepLinkToContactsApp',
+                               'generateSubline',
+                       ])
+                       ->getMock();
+
+               $provider->expects($this->once())
+                       ->method('getDavUrlForContact')
+                       ->with('principals/users/john.doe', 'addressbook-uri-123', 'vcard1.vcf')
+                       ->willReturn('absolute-thumbnail-url');
+
+               $provider->expects($this->exactly(2))
+                       ->method('generateSubline')
+                       ->willReturn('subline');
+               $provider->expects($this->exactly(2))
+                       ->method('getDeepLinkToContactsApp')
+                       ->withConsecutive(
+                               ['addressbook-uri-99', 'Test'],
+                               ['addressbook-uri-123', 'Test2']
+                       )
+                       ->willReturn('deep-link-to-contacts');
+
+               $actual = $provider->search($user, $query);
+               $data = $actual->jsonSerialize();
+               $this->assertInstanceOf(SearchResult::class, $actual);
+               $this->assertEquals('Contacts', $data['name']);
+               $this->assertCount(2, $data['entries']);
+               $this->assertTrue($data['isPaginated']);
+               $this->assertEquals(22, $data['cursor']);
+
+               $result0 = $data['entries'][0];
+               $result0Data = $result0->jsonSerialize();
+               $result1 = $data['entries'][1];
+               $result1Data = $result1->jsonSerialize();
+
+               $this->assertInstanceOf(ContactsSearchResultEntry::class, $result0);
+               $this->assertEquals('', $result0Data['thumbnailUrl']);
+               $this->assertEquals('FN of Test', $result0Data['title']);
+               $this->assertEquals('subline', $result0Data['subline']);
+               $this->assertEquals('deep-link-to-contacts', $result0Data['resourceUrl']);
+               $this->assertEquals('icon-contacts-dark', $result0Data['iconClass']);
+               $this->assertTrue($result0Data['rounded']);
+
+               $this->assertInstanceOf(ContactsSearchResultEntry::class, $result0);
+               $this->assertEquals('absolute-thumbnail-url?photo', $result1Data['thumbnailUrl']);
+               $this->assertEquals('FN of Test2', $result1Data['title']);
+               $this->assertEquals('subline', $result1Data['subline']);
+               $this->assertEquals('deep-link-to-contacts', $result1Data['resourceUrl']);
+               $this->assertEquals('icon-contacts-dark', $result1Data['iconClass']);
+               $this->assertTrue($result1Data['rounded']);
+       }
+
+       public function testGetDavUrlForContact(): void {
+               $this->urlGenerator->expects($this->once())
+                       ->method('linkTo')
+                       ->with('', 'remote.php')
+                       ->willReturn('link-to-remote.php');
+               $this->urlGenerator->expects($this->once())
+                       ->method('getAbsoluteURL')
+                       ->with('link-to-remote.php/dav/addressbooks/users/john.doe/foo/bar.vcf')
+                       ->willReturn('absolute-url-link-to-remote.php/dav/addressbooks/users/john.doe/foo/bar.vcf');
+
+               $actual = self::invokePrivate($this->provider, 'getDavUrlForContact', ['principals/users/john.doe', 'foo', 'bar.vcf']);
+
+               $this->assertEquals('absolute-url-link-to-remote.php/dav/addressbooks/users/john.doe/foo/bar.vcf', $actual);
+       }
+
+       public function testGetDeepLinkToContactsApp(): void {
+               $this->urlGenerator->expects($this->once())
+                       ->method('linkToRoute')
+                       ->with('contacts.contacts.direct', ['contact' => 'uid123~uri-john.doe'])
+                       ->willReturn('link-to-route-contacts.contacts.direct/direct/uid123~uri-john.doe');
+               $this->urlGenerator->expects($this->once())
+                       ->method('getAbsoluteURL')
+                       ->with('link-to-route-contacts.contacts.direct/direct/uid123~uri-john.doe')
+                       ->willReturn('absolute-url-link-to-route-contacts.contacts.direct/direct/uid123~uri-john.doe');
+
+               $actual = self::invokePrivate($this->provider, 'getDeepLinkToContactsApp', ['uri-john.doe', 'uid123']);
+               $this->assertEquals('absolute-url-link-to-route-contacts.contacts.direct/direct/uid123~uri-john.doe', $actual);
+       }
+
+       public function testGenerateSubline(): void {
+               $vCard0 = Reader::read($this->vcardTest0);
+               $vCard1 = Reader::read($this->vcardTest1);
+
+               $actual1 = self::invokePrivate($this->provider, 'generateSubline', [$vCard0]);
+               $actual2 = self::invokePrivate($this->provider, 'generateSubline', [$vCard1]);
+
+               $this->assertEquals('forrestgump@example.com', $actual1);
+               $this->assertEquals('', $actual2);
+       }
+}