Viewing File: /usr/local/cpanel/base/3rdparty/roundcube/plugins/carddav/tests/Unit/AddressbookTest.php

<?php

/*
 * RCMCardDAV - CardDAV plugin for Roundcube webmail
 *
 * Copyright (C) 2011-2022 Benjamin Schieder <rcmcarddav@wegwerf.anderdonau.de>,
 *                         Michael Stilkerich <ms@mike2k.de>
 *
 * This file is part of RCMCardDAV.
 *
 * RCMCardDAV is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 2 of the License, or
 * (at your option) any later version.
 *
 * RCMCardDAV 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with RCMCardDAV. If not, see <https://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace MStilkerich\Tests\RCMCardDAV\Unit;

use PHPUnit\Framework\TestCase;
use MStilkerich\Tests\RCMCardDAV\TestInfrastructure;
use MStilkerich\RCMCardDAV\{Addressbook,DataConversion,DelayedPhotoLoader};
use MStilkerich\RCMCardDAV\Db\AbstractDatabase;
use MStilkerich\RCMCardDAV\Db\Database;
use MStilkerich\RCMCardDAV\Db\DbAndCondition;
use MStilkerich\RCMCardDAV\Frontend\AddressbookManager;
use MStilkerich\CardDavClient\AddressbookCollection;
use rcube_addressbook;

/**
 * @psalm-import-type FullAccountRow from AbstractDatabase
 * @psalm-import-type FullAbookRow from AbstractDatabase
 * @psalm-import-type SaveData from DataConversion
 *
 * @psalm-import-type Int1 from AddressbookManager
 * @psalm-import-type AccountCfg from AddressbookManager
 * @psalm-import-type AbookCfg from AddressbookManager
 *
 * @psalm-type CfgOverride = array{
 *   refresh_time?: numeric-string,
 *   last_updated?: numeric-string,
 *   readonly?: Int1,
 *   require_always_email?: Int1,
 * }
 */
final class AddressbookTest extends TestCase
{
    /** @var JsonDatabase */
    private $db;

    public static function setUpBeforeClass(): void
    {
        $_SESSION['user_id'] = 1000;
        $_SESSION['username'] = 'user@example.com';
    }

    public function setUp(): void
    {
        $this->db = new JsonDatabase();
        $cache = $this->createMock(\rcube_cache::class);
        TestInfrastructure::init($this->db);
        TestInfrastructure::$infra->setCache($cache);
    }

    public function tearDown(): void
    {
        Utils::cleanupTempImages();
        TestInfrastructure::logger()->reset();
    }

    /**
     * Adds the addressbook flags to an addressbook DB row to create an addressbook config.
     * @param FullAbookRow $abookRow
     * @return AbookCfg
     */
    private function addABookFlags(array $abookRow): array
    {
        foreach (AbstractDatabase::FLAGS_COLS['addressbooks']['fields'] as $attr => $bitPos) {
            $flags = intval($abookRow['flags']);
            $abookRow[$attr] = ($flags & (1 << $bitPos)) ? '1' : '0';
        }

        return $abookRow;
    }

    /**
     * Adds the account flags to an account DB row to create an account config.
     * @param FullAccountRow $accountRow
     * @return AccountCfg
     */
    private function addAccountFlags(array $accountRow): array
    {
        foreach (AbstractDatabase::FLAGS_COLS['accounts']['fields'] as $attr => $bitPos) {
            $flags = intval($accountRow['flags']);
            $accountRow[$attr] = ($flags & (1 << $bitPos)) ? '1' : '0';
        }

        return $accountRow;
    }

    /**
     * @param CfgOverride $cfgOverride
     */
    private function createAbook(array $cfgOverride = []): Addressbook
    {
        $db = $this->db;
        $db->importData('tests/Unit/data/syncHandlerTest/initial/db.json');

        /** @var FullAbookRow */
        $abookCfg = $db->lookup("42", [], 'addressbooks');
        $abookCfg = $this->addABookFlags($abookCfg);
        // Override config settings
        $abookCfg = array_merge($abookCfg, $cfgOverride);

        /** @var FullAccountRow */
        $accountCfg = $db->lookup($abookCfg['account_id'], [], 'accounts');
        $accountCfg = $this->addAccountFlags($accountCfg);

        $account = TestInfrastructure::$infra->makeAccount($accountCfg);
        $abook = new Addressbook("42", $account, $abookCfg);
        $davobj = $this->createStub(AddressbookCollection::class);
        $davobj->method('downloadResource')->will($this->returnCallback([Utils::class, 'downloadResource']));
        TestInfrastructure::setPrivateProperty($abook, 'davAbook', $davobj);

        return $abook;
    }

    /**
     * Tests that a newly constructed addressbook has the expected values in its public properties.
     */
    public function testAddressbookHasExpectedPublicPropertyValues(): void
    {
        $db = $this->db;
        $abook = $this->createAbook();

        $this->assertSame('id', $abook->primary_key);
        $this->assertSame(true, $abook->groups);
        $this->assertSame(true, $abook->export_groups);
        $this->assertSame(false, $abook->readonly);
        $this->assertSame(false, $abook->searchonly);
        $this->assertSame(false, $abook->undelete);
        $this->assertSame(true, $abook->ready);
        $this->assertSame('name', $abook->sort_col);
        $this->assertSame('ASC', $abook->sort_order);
        $this->assertSame(['birthday', 'anniversary'], $abook->date_cols);
        $this->assertNull($abook->group_id);

        $this->assertIsArray($abook->coltypes['email']);
        $this->assertIsArray($abook->coltypes['email']['subtypes']);
        $this->assertContains("SpecialLabel", $abook->coltypes['email']['subtypes']);

        $this->assertSame("Test Addressbook", $abook->get_name());
        $this->assertSame("42", $abook->getId());

        /** @var FullAbookRow */
        $abookCfg = $db->lookup("42", [], 'addressbooks');
        $abookCfg = $this->addABookFlags($abookCfg);
        $abookCfg['readonly'] = '1';

        /** @var FullAccountRow */
        $accountCfg = $db->lookup($abookCfg['account_id'], [], 'accounts');
        $accountCfg = $this->addAccountFlags($accountCfg);
        $account = TestInfrastructure::$infra->makeAccount($accountCfg);

        $roAbook = new Addressbook("42", $account, $abookCfg);
        $this->assertSame(true, $roAbook->readonly);
    }

    /**
     * @return list<array{int,string,?string,int,int,null|0|string,?list<string>,bool,int,list<string>}>
     */
    public function listRecordsDataProvider(): array
    {
        return [
            // subset, sort_col, sort_order, page, pagesize, group, cols, reqCols, expCount, expRecords
            [ 0,  'name', 'ASC',  1, 10,  0,     null, false, 6, ["56", "51", "50", "52", "53", "54"] ],
            [ 0,  'name', 'DESC', 1, 10, "0",    null, false, 6, ["54", "53", "52", "50", "51", "56"] ],
            [ 0,  'firstname', null,  1, 10,     null, null, false, 6, ["51", "52", "53", "56", "50", "54"] ],
            [ 0,  'name', 'ASC',  1,  4,  0,     null, false, 6, ["56", "51", "50", "52"] ],
            [ 0,  'name', 'ASC',  2,  4,  0,     null, false, 6, ["53", "54"] ],
            [ 0,  'name', 'DESC', 3,  2,  0,     null, false, 6, ["51", "56"] ],
            [ 1,  'name', 'DESC', 2,  2,  0,     null, false, 6, ["52"] ],
            [ 2,  'name', 'DESC', 2,  2,  0,     null, false, 6, ["52", "50"] ],
            [ 3,  'name', 'DESC', 2,  2,  0,     null, false, 6, ["52", "50"] ],
            [ -1, 'name', 'DESC', 2,  2,  0,     null, false, 6, ["50"] ],
            [ -2, 'name', 'DESC', 2,  2,  0,     null, false, 6, ["52", "50"] ],
            [ -3, 'name', 'DESC', 2,  2,  0,     null, false, 6, ["52", "50"] ],
            [ 0,  'name', 'ASC',  1, 10,  "500", null, false, 2, ["56", "50"] ],
            [ 0,  'name', 'ASC',  1, 10,  0, ['name','email'], false, 6, ["56", "51", "50", "52", "53", "54"] ],
            [ 0,  'name', 'ASC',  1, 10,  0, ['organization', 'firstname'],
                                                       false, 6, ["56", "51", "50", "52", "53", "54"] ],
            [ 0,  'name', 'ASC',  1, 10,  0,     null, true,  5, ["51", "50", "52", "53", "54"] ],
            [ 0,  'name', 'ASC',  2,  2,  0,     null, true,  5, ["52", "53"] ],
        ];
    }

    /**
     * Tests list_records()
     *
     * @dataProvider listRecordsDataProvider
     * @param list<string> $expRecords
     * @param null|0|string $gid
     * @param ?list<string> $cols
     */
    public function testListRecordsReturnsExpectedRecords(
        int $subset,
        string $sortCol,
        ?string $sortOrder,
        int $page,
        int $pagesize,
        $gid,
        ?array $cols,
        bool $reqEmail,
        int $expCount,
        array $expRecords
    ): void {
        $abook = $this->createAbookForSearchTest($sortCol, $sortOrder, $page, $pagesize, $gid, $reqEmail);

        $rset = $abook->list_records($cols, $subset);
        $this->assertNull($abook->get_error());
        $this->assertSame($rset, $abook->get_result(), "Get result does not return last result set");
        $this->assertSame(($page - 1) * $pagesize, $rset->first);
        $this->assertFalse($rset->searchonly);
        $this->assertSame($expCount, $rset->count);
        $this->assertCount(count($expRecords), $rset->records);

        $lrOrder = array_column($rset->records, 'ID');
        $this->assertSame($expRecords, $lrOrder, "Card order mismatch");
        for ($i = 0; $i < count($expRecords); ++$i) {
            $id = $expRecords[$i];
            $fn = "tests/Unit/data/addressbookTest/c{$id}.json";
            $saveDataExp = Utils::readSaveDataFromJson($fn);
            if (isset($cols)) {
                $saveDataExp = $this->stripSaveDataToDbColumns($saveDataExp, $cols);
            }
            /** @var array $saveDataRc */
            $saveDataRc = $rset->records[$i];
            Utils::compareSaveData($saveDataExp, $saveDataRc, "Unexpected record data $id");

            if (isset($saveDataExp['photo']) && empty($saveDataExp['photo'])) {
                $this->assertPhotoDownloadWarning();
            }
        }
    }

    /**
     * Initializes an addressbook for the tests of list_records(), count() and search().
     *
     * @param null|0|string $gid
     */
    private function createAbookForSearchTest(
        string $sortCol,
        ?string $sortOrder,
        int $page,
        int $pagesize,
        $gid,
        bool $reqEmail
    ): Addressbook {
        $abook = $this->createAbook(['require_always_email' => ($reqEmail ? '1' : '0')]);
        $abook->set_page($page);
        $this->assertSame($page, $abook->list_page);
        $abook->set_pagesize($pagesize);
        $this->assertSame($pagesize, $abook->page_size);
        $abook->set_sort_order($sortCol, $sortOrder);
        $this->assertSame($sortCol, $abook->sort_col);
        $this->assertSame($sortOrder ?? 'ASC', $abook->sort_order);

        $abook->set_group($gid);
        if ($gid) {
            $this->assertSame($gid, $abook->group_id);
        } else {
            $this->assertNull($abook->group_id);
        }

        return $abook;
    }

    /**
     * Tests count()
     *
     * @dataProvider listRecordsDataProvider
     * @param list<string> $expRecords
     * @param null|0|string $gid
     * @param ?list<string> $cols
     */
    public function testCountProvidesExpectedNumberOfRecords(
        int $subset,
        string $sortCol,
        ?string $sortOrder,
        int $page,
        int $pagesize,
        $gid,
        ?array $cols,
        bool $reqEmail,
        int $expCount,
        array $expRecords
    ): void {
        $abook = $this->createAbookForSearchTest($sortCol, $sortOrder, $page, $pagesize, $gid, $reqEmail);
        $rset = $abook->count();
        $this->assertNull($abook->get_error());
        $this->assertSame(($page - 1) * $pagesize, $rset->first);
        $this->assertFalse($rset->searchonly);
        $this->assertSame($expCount, $rset->count);
        $this->assertCount(0, $rset->records);
    }

    /**
     * @return array<string, array{
     *     string|string[], string|string[], int, bool, bool,
     *     string,?string,int,int,
     *     null|0|string,
     *     bool,
     *     string|string[],
     *     int,list<string>
     * }>
     */
    public function searchDataProvider(): array
    {
        return [
            'Direct ID search single id' => [
                'ID', "50", 0, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["50"]
            ],
            'Direct ID search with key property from addressbook' => [
                'id', "50", 0, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["50"]
            ],
            'Direct ID search multiple id as string' => [
                'ID', "50,56,52,70", 0, true, false, // 70 belongs to a different addressbook and must not be returned
                'name', 'ASC', 1, 10,
                0,
                false, [],
                3, ["56", "50", "52"]
            ],
            'Direct ID search multiple id as array' => [
                'ID', ["50", "56", "52"], 0, true, false,
                'name', 'DESC', 1, 10,
                0,
                false, [],
                3, ["52", "50", "56"]
            ],
            'Single DB field search with prefix search' => [
                ['name'], ["ROB"], rcube_addressbook::SEARCH_PREFIX, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["53"]
            ],
            'Single Multivalue DB field search with contains search' => [
                ['email'], ["north@7kingdoms.com"], rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                2, ["60", "50"]
            ],
            'Single Multivalue DB structured-content field search with contains search' => [
                ['address'], ["Kanto"], rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["60"]
            ],
            'Single Multivalue DB field search with strict search' => [
                ['email'], ["north@7kingdoms.com"], rcube_addressbook::SEARCH_STRICT, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["60"]
            ],
            'Multi DB field search with contains search' => [
                ['name', 'email'], ["Lannister", "@example"], rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["51"]
            ],
            'Multi DB/vcard field search with contains search' => [
                ['name', 'jobtitle'], ["Stark", "Warden"], rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["50"]
            ],
            'Vcard field search with post-search drop' => [ // vcard as a whole matches, but not the asked for field
                ['assistant'], ["Wesker"], rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                0, []
            ],
            'Vcard field search with exact match' => [
                ['assistant'], ["Dr. Alexander Isaacs"], rcube_addressbook::SEARCH_STRICT, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["60"]
            ],
            'Vcard field search with exact match (no match)' => [
                ['assistant'], ["Dr. Alexander Isaac"], rcube_addressbook::SEARCH_STRICT, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                0, []
            ],
            'Multi Vcard field search with exact match' => [
                ['assistant', 'manager'], ["dr. alex", "no "],
                rcube_addressbook::SEARCH_PREFIX, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["60"]
            ],
            'All fields search' => [
                '*', 'Birkin',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                1, ["60"]
            ],
            'All fields search (no matches)' => [
                '*', 'Birkin22',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                0, []
            ],
            'Two fields OR search' => [
                ['organization', 'name'], 'the',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                3, ["50", "53", "54"]
            ],
            'Mixed DB/vcard fields OR search' => [
                ['notes', 'organization', 'name'], 'the',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, [],
                4, ["60", "50", "53", "54"]
            ],
            'Results for 2nd page only' => [
                '*', 'example',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 2, 2,
                0,
                false, [],
                6, ["50", "52"]
            ],
            'Results for 2nd page only (select only)' => [
                '*', 'example',
                rcube_addressbook::SEARCH_ALL, true, true,
                'name', 'ASC', 2, 2,
                0,
                false, [],
                2, ["50", "52"]
            ],
            'Results for 2nd page only (count only)' => [
                '*', 'example',
                rcube_addressbook::SEARCH_ALL, false, false,
                'name', 'ASC', 2, 2,
                0,
                false, [],
                6, []
            ],
            'With required DB field' => [
                '*', 'stark',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, ['firstname'],
                2, ["56", "50"]
            ],
            'With required DB field (plus required abook field)' => [
                '*', 'stark',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                true, ['firstname'],
                1, ["50"]
            ],
            'With required VCard field' => [
                '*', 'example',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, ['jobtitle'],
                2, ["60", "50"]
            ],
            'With required VCard field (as string)' => [
                '*', 'example',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                0,
                false, 'jobtitle',
                2, ["60", "50"]
            ],
            'With required VCard field and group filter' => [
                '*', 'example',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                "500",
                false, 'jobtitle',
                1, ["50"]
            ],
            'With required VCard field and group filter for empty group' => [
                '*', 'example',
                rcube_addressbook::SEARCH_ALL, true, false,
                'name', 'ASC', 1, 10,
                "504",
                false, 'jobtitle',
                0, []
            ],
        ];
    }

    /**
     * Tests the search() function.
     *
     * @dataProvider searchDataProvider
     * @param string|string[] $fields
     * @param string|string[] $value
     * @param null|0|string $gid
     * @param bool $reqEmail If true, set require_always_email parameter for the addressbook
     * @param string|string[] $reqColsCl Required columns search() parameter
     * @param list<string> $expRecords
     */
    public function testSearchReturnsExpectedRecords(
        $fields,
        $value,
        int $mode,
        bool $select,
        bool $nocount,
        string $sortCol,
        ?string $sortOrder,
        int $page,
        int $pagesize,
        $gid,
        bool $reqEmail,
        $reqColsCl,
        int $expCount,
        array $expRecords
    ): void {
        $abook = $this->createAbookForSearchTest($sortCol, $sortOrder, $page, $pagesize, $gid, $reqEmail);
        $db = $this->db;
        $db->importData('tests/Unit/data/addressbookTest/db2.json');

        // Try with search() and a second time with set_search_set() + list_records()
        for ($run = 0; $run < 2; ++$run) {
            if ($run == 0) {
                $rset = $abook->search($fields, $value, $mode, $select, $nocount, $reqColsCl);
            } else {
                // After the search, the search filter should be installed
                $filter = $abook->get_search_set();
                $this->assertNotEmpty($filter);
                $abook->reset();
                $this->assertEmpty($abook->get_search_set());
                $this->assertNull($abook->get_result());

                $abook->set_search_set($filter);
                $rset = $abook->list_records(null, 0, $nocount);
            }

            $this->assertNull($abook->get_error());
            $this->assertSame($rset, $abook->get_result(), "Search does not return last result set");
            $this->assertSame(($page - 1) * $pagesize, $rset->first);
            $this->assertFalse($rset->searchonly);

            if ($nocount) {
                $this->assertSame(count($rset->records), $rset->count);
            } else {
                $this->assertSame($expCount, $rset->count);
            }

            if ($run == 0 || $select) { // select=false can only be tested with search() (run 0)
                $this->assertCount(count($expRecords), $rset->records);

                $lrOrder = array_column($rset->records, 'ID');
                $this->assertSame($expRecords, $lrOrder, "Card order mismatch (run $run)");
                for ($i = 0; $i < count($expRecords); ++$i) {
                    $id = $expRecords[$i];
                    $fn = "tests/Unit/data/addressbookTest/c{$id}.json";
                    $saveDataExp = Utils::readSaveDataFromJson($fn);
                    /** @var array $saveDataRc */
                    $saveDataRc = $rset->records[$i];
                    Utils::compareSaveData($saveDataExp, $saveDataRc, "Unexpected record data $id");

                    if (isset($saveDataExp['photo']) && empty($saveDataExp['photo'])) {
                        $this->assertPhotoDownloadWarning();
                    }
                }
            }
        }
    }

    /**
     * @return array<string, array{mixed}>
     */
    public function invalidFilterProvider(): array
    {
        return [
            'SQL string' => [ 'WHERE name="foo"' ],
            'Mixed DbAndCondition array' => [ [ new DbAndCondition(), 'WHERE name="foo"' ] ],
        ];
    }

    /**
     * Tests that set_search_set() throws an error when given an invalid filter type.
     *
     * @dataProvider invalidFilterProvider
     * @param mixed $filter
     */
    public function testSetSearchSetThrowsErrorOnInvalidFilter($filter): void
    {
        $abook = $this->createAbook();
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('requires a DbAndCondition[] type filter');
        $abook->set_search_set($filter);
    }

    /**
     * Tests that set_group() ignores an invalid group id and logs an error.
     */
    public function testSetInvalidGroupIgnored(): void
    {
        $abook = $this->createAbook();
        $abook->set_group("12345");
        $this->assertNull($abook->group_id, "Group ID changed after set_group with invalid ID");
        $logger = TestInfrastructure::logger();
        $logger->expectMessage('error', 'set_group(12345)');
    }

    /** @return array<string, array{string,bool,bool}> */
    public function getRecordProvider(): array
    {
        return [
            'Valid ID' => [ '50', true, false ],
            'Valid ID (rset)' => [ '50', false, false ],
            'Valid ID (different addressbook)' => [ '70', true, true ],
            'Invalid ID' => [ '500', true, true ],
            'Invalid ID (rset)' => [ '500', false, true ],
        ];
    }

    /**
     * Tests that get_record() returns expected record.
     *
     * @dataProvider getRecordProvider
     */
    public function testGetRecordProvidesExpectedRecord(string $id, bool $assoc, bool $expError): void
    {
        $abook = $this->createAbook();
        $db = $this->db;

        // import contact belonging to different addressbook
        $db->importData('tests/Unit/data/addressbookTest/db2.json');

        $saveDataRc = $abook->get_record($id, $assoc);

        if ($expError) {
            if (!$assoc) {
                $this->assertInstanceOf(\rcube_result_set::class, $saveDataRc);
                $this->assertSame($saveDataRc->count, 0);
                $this->assertSame($saveDataRc->first, 0);
                $this->assertCount(0, $saveDataRc->records);
                $this->assertNull($abook->get_result());
            }
            $logger = TestInfrastructure::logger();
            $logger->expectMessage('error', "Could not get contact $id");

            $abookErr = $abook->get_error();
            $this->assertIsArray($abookErr);
            $this->assertSame(rcube_addressbook::ERROR_SEARCH, $abookErr['type']);
            $this->assertStringContainsString("Could not get contact $id", (string) $abookErr['message']);
        } else {
            if (!$assoc) {
                $this->assertInstanceOf(\rcube_result_set::class, $saveDataRc);
                $this->assertSame($saveDataRc, $abook->get_result());
                $this->assertSame($saveDataRc->count, 1);
                $this->assertSame($saveDataRc->first, 0);
                $this->assertCount(1, $saveDataRc->records);
                $this->assertIsArray($saveDataRc->records[0]);
                $saveDataRc = $saveDataRc->records[0];
            }

            $this->assertIsArray($saveDataRc);
            // we must use a record with an URI photo to check it remains wrapped in a photo loader
            $this->assertInstanceOf(DelayedPhotoLoader::class, $saveDataRc['photo'], "photo not wrapped");

            $fn = "tests/Unit/data/addressbookTest/c{$id}.json";
            $saveDataExp = Utils::readSaveDataFromJson($fn);
            Utils::compareSaveData($saveDataExp, $saveDataRc, "Unexpected record data $id");

            if (isset($saveDataExp['photo']) && empty($saveDataExp['photo'])) {
                $this->assertPhotoDownloadWarning();
            }
        }
    }

    /**
     * @return list<array{?string, int, list<string>}>
     */
    public function groupFilterProvider(): array
    {
        return [
            [ null, 0, ["506", "501", "502", "500", "503", "504"] ],
            [ "House", rcube_addressbook::SEARCH_PREFIX, ["501", "502", "500"] ],
            [ "ar", 0, ["501", "500"] ],
            [ "ar", rcube_addressbook::SEARCH_ALL, ["501", "500"] ],
            [ "Kings", rcube_addressbook::SEARCH_STRICT, ["503"] ],
            [ "House", rcube_addressbook::SEARCH_STRICT, [] ],
        ];
    }

    /**
     * Tests that groups matching the given filter are listed.
     *
     * @dataProvider groupFilterProvider
     * @param list<string> $expRecords
     */
    public function testListGroupsProvidesExpectedGroups(?string $filter, int $searchmode, array $expRecords): void
    {
        $abook = $this->createAbook();
        $groups = $abook->list_groups($filter, $searchmode);

        $this->assertNull($abook->get_error());
        $this->assertCount(count($expRecords), $groups);

        $lrOrder = array_column($groups, 'ID');
        $this->assertSame($expRecords, $lrOrder, "Group order mismatch");
        for ($i = 0; $i < count($expRecords); ++$i) {
            $id = $expRecords[$i];
            $fn = "tests/Unit/data/addressbookTest/g{$id}.json";
            $saveDataExp = Utils::readSaveDataFromJson($fn);
            $saveDataRc = $groups[$i];
            Utils::compareSaveData($saveDataExp, $saveDataRc, "Unexpected record data $id");
        }
    }

    /** @return array<string, array{string,bool}> */
    public function getGroupProvider(): array
    {
        return [
            'Valid ID' => [ '500', false ],
            'Valid ID (different addressbook)' => [ '700', true ],
            'Invalid ID' => [ '50', true ],
        ];
    }

    /**
     * Tests that get_group() returns expected record.
     *
     * @dataProvider getGroupProvider
     */
    public function testGetGroupProvidesExpectedRecord(string $id, bool $expError): void
    {
        $abook = $this->createAbook();
        $db = $this->db;

        // import contact belonging to different addressbook
        $db->importData('tests/Unit/data/addressbookTest/db2.json');

        $saveDataRc = $abook->get_group($id);

        if ($expError) {
            $this->assertNull($saveDataRc);

            $logger = TestInfrastructure::logger();
            $logger->expectMessage('error', "Could not get group");

            $abookErr = $abook->get_error();
            $this->assertIsArray($abookErr);
            $this->assertSame(rcube_addressbook::ERROR_SEARCH, $abookErr['type']);
            $this->assertStringContainsString("Could not get group $id", (string) $abookErr['message']);
        } else {
            $this->assertIsArray($saveDataRc);

            $fn = "tests/Unit/data/addressbookTest/g{$id}.json";
            $saveDataExp = Utils::readSaveDataFromJson($fn);
            Utils::compareSaveData($saveDataExp, $saveDataRc, "Unexpected record data $id");
        }
    }

    /**
     * @return list<array{int,numeric-string,numeric-string,int}>
     */
    public function resyncDueProvider(): array
    {
        $now = time();
        $nowStr = (string) $now;

        return [
            // now  refresh  lastup  expDue
            [ $now, "3600", $nowStr, 3600 ],
            [ $now, "0", "0", -$now ],
            [ $now, "0", $nowStr, 0 ],
            [ $now, "1", $nowStr, 1 ],
            [ $now, "1", (string) ($now - 1), 0 ],
            [ $now, "1", (string) ($now - 2), -1 ],
        ];
    }

    /**
     * Tests getRefreshTime() and checkResyncDue().
     *
     * @psalm-param numeric-string $rt
     * @psalm-param numeric-string $lu
     * @dataProvider resyncDueProvider
     */
    public function testCheckResyncDueProvidesExpDelta(int $now, string $rt, string $lu, int $expDue): void
    {
        $abook = $this->createAbook(['refresh_time' => $rt, 'last_updated' => $lu]);
        $this->assertSame(intval($rt), $abook->getRefreshTime());

        // allow a tolerance of 1 second
        $nowDelta = time() - $now; // delta to now in data provider
        $expDue -= $nowDelta;
        $this->assertLessThanOrEqual(1, $expDue - $abook->checkResyncDue());
    }

    /**
     * Asserts that a warning message concerning failure to download the photo has been issued for VCards where an
     * invalid Photo URI is used.
     */
    private function assertPhotoDownloadWarning(): void
    {
        $logger = TestInfrastructure::logger();
        $logger->expectMessage(
            'warning',
            'downloadPhoto: Attempt to download photo from http://localhost/doesNotExist.jpg failed'
        );
    }

    /**
     * Given a full save_data array, it constrains/converts the data such that it only contains fields
     * that are present in the given columns.
     *
     * @param SaveData $saveData
     * @param list<string> $cols
     * @return SaveData
     */
    private function stripSaveDataToDbColumns(array $saveData, array $cols): array
    {
        $cols[] = 'ID'; // always keep ID in the result

        foreach ($saveData as $k => $v) {
            // strip subtype from multi-value objects
            $kgen = preg_replace('/:.*/', '', $k);
            if (in_array($kgen, $cols)) {
                if ($kgen != $k) {
                    /** @var list<string> $oldv */
                    $oldv = $saveData[$kgen] ?? [];
                    /** @var list<string> $v */
                    $saveData[$kgen] = array_merge($oldv, $v);
                    unset($saveData[$k]);
                }
            } else {
                unset($saveData[$k]);
            }
        }

        /** @var SaveData $saveData */
        return $saveData;
    }
}

// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120
Back to Directory File Manager