Viewing File: /usr/local/cpanel/base/3rdparty/roundcube/plugins/carddav/tests/Unit/UITest.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 Exception;
use DOMDocument;
use DOMXPath;
use DOMNodeList;
use DOMNode;
use DOMNamedNodeMap;
use MStilkerich\CardDavClient\{Account,AddressbookCollection,WebDavResource};
use MStilkerich\CardDavClient\Services\{Discovery,Sync};
use MStilkerich\RCMCardDAV\Db\AbstractDatabase;
use MStilkerich\RCMCardDAV\Frontend\{AddressbookManager,UI};
use MStilkerich\RCMCardDAV\RoundcubeLogger;
use MStilkerich\Tests\RCMCardDAV\TestInfrastructure;
use PHPUnit\Framework\TestCase;

/**
 * Tests parts of the AdminSettings class using test data in JsonDatabase.
 * @psalm-import-type PsrLogLevel from RoundcubeLogger
 * @psalm-import-type DbConditions from AbstractDatabase
 */
final class UITest extends TestCase
{
    /** @var JsonDatabase */
    private $db;

    public static function setUpBeforeClass(): void
    {
        $_SESSION['user_id'] = 105;
        $_SESSION['username'] = 'johndoe';
    }

    public function setUp(): void
    {
    }

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

    public function testUiSettingsActionGeneratesProperSectionInfo(): void
    {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $settingsEntry = $ui->addSettingsAction(['attrib' => ['foo' => 'bar'], 'actions' => [['action' => 'test']]]);

        $this->assertSame(['foo' => 'bar'], $settingsEntry['attrib'] ?? []);
        $this->assertCount(2, $settingsEntry['actions']);
        $this->assertSame(['action' => 'test'], $settingsEntry['actions'][0]);
        $this->assertIsArray($settingsEntry['actions'][1]);
        $this->assertSame('plugin.carddav', $settingsEntry['actions'][1]['action'] ?? '');
        $this->assertSame('cd_preferences', $settingsEntry['actions'][1]['class'] ?? '');
        $this->assertSame('CardDAV_rclbl', $settingsEntry['actions'][1]['label'] ?? '');
        $this->assertSame('CardDAV_rctit', $settingsEntry['actions'][1]['title'] ?? '');
        $this->assertSame('carddav', $settingsEntry['actions'][1]['domain'] ?? '');
    }

    /**
     * Tests that the list of accounts/addressbooks in the carddav settings pane is properly generated.
     *
     * - Accounts and addressbooks are sorted alphabetically
     * - Preset accounts are tagged with preset class
     * - Accounts with hide=true are hidden
     * - Active toggle of the addressbooks is set to the correct initial value
     * - Active toggle for preset accounts' addressbooks with active=fixed is disabled
     */
    public function testAddressbookListIsProperlyCreated(): void
    {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);

        $ui->renderAddressbookList();
        $this->assertContains('carddav.addressbooks', $rcStub->sentTemplates);

        $html = $ui->tmplAddressbooksList(['id' => 'addressbooks-table']);
        $this->assertNotEmpty($html);

        /**
         * Expected accounts in the expected order
         * @psalm-var list<list{string,int,list<string>}> $expAccounts
         */
        $expAccounts = [
            //  name        , ID, Preset?
            [ 'iCloud', 43, false ], // the lowercase initial must sort alphabetically with the uppercase initials
            [ 'Preset Contacts', 44, true ],
            [ 'Test Account', 42, false ],
            // HiddenPreset is excluded as it must not be shown in the settings pane
        ];

        /**
         * Expected addressbooks per account in the expected order
         * @psalm-var array<string, list<list{string,int,bool,bool}>> $expAbooks
         */
        $expAbooks = [
            //  name        , ID, active?, activeFixed?
            'iCloud' => [
            ],
            'Preset Contacts' => [
                ['Public readonly contacts', 51, true, true],
            ],
            'Test Account' => [
                ['additional contacts', 43, true, false],
                ['Additional contacts - Inactive', 44, false, false],
                ['Basic contacts', 42, true, false],
            ],
        ];

        $dom = new DOMDocument();
        $this->assertTrue($dom->loadHTML($html));
        $xpath = new DOMXPath($dom);

        // for each account, there must be an li node with the id rcmli_acc<ID>
        $accItems = $xpath->query("//ul[@id='addressbooks-table']/li");
        $this->assertInstanceOf(DOMNodeList::class, $accItems);
        $this->assertCount(count($expAccounts), $accItems);
        for ($i = 0; $i < count($expAccounts); $i++) {
            [ $accName, $accId, $isPreset ] = $expAccounts[$i];
            $accItem = $accItems->item($i);
            $this->assertInstanceOf(DOMNode::class, $accItem);

            // Check attributes
            $this->checkAttribute($accItem, 'id', "rcmli_acc$accId");
            $this->checkAttribute($accItem, 'class', 'account', 'contains');
            $this->checkAttribute($accItem, 'class', 'preset', $isPreset ? 'contains' : 'containsnot');

            // Check account name, which is stored in a span element
            $this->checkAccAbName($xpath, $accItem, $accName);

            // check the addressbooks shown are as expected, including order, classes and active toggle status
            $abookItems = $xpath->query("ul/li", $accItem);
            $expAbookRecords = $expAbooks[$accName];
            $this->assertCount(count($expAbookRecords), $abookItems);
            for ($j = 0; $j < count($expAbookRecords); $j++) {
                [ $abName, $abId, $abAct, $abActFixed ] = $expAbookRecords[$j];
                $abookItem = $abookItems->item($j);
                $this->assertInstanceOf(DOMNode::class, $abookItem);

                // Check attributes
                $this->checkAttribute($abookItem, 'id', "rcmli_abook$abId");
                $this->checkAttribute($abookItem, 'class', 'addressbook', 'contains');

                // Check displayed addressbook name
                $this->checkAccAbName($xpath, $abookItem, $abName);

                // Check active toggle
                $actToggle = $this->getDomNode($xpath, "a/input[@type='checkbox']", $abookItem);
                $this->checkAttribute($actToggle, 'value', "$abId");
                $this->checkAttribute($actToggle, 'name', "_active[]");
                $this->checkAttribute($actToggle, 'checked', $abAct ? 'checked' : null);
                $this->checkAttribute($actToggle, 'disabled', $abActFixed ? 'disabled' : null);
            }
        }
    }

    /**
     * GET: account ID to set as GET parameter (null to not set one)
     * ERR: expected error message. Null if no error is expected, empty string if error case without error message
     *
     *                             GET      ERR     Check-inputs
     * @return array<string, list{?string, ?string, list<list{string,?string,string,string, ?string}>}>
     */
    public function accountIdProvider(): array
    {
        $lblTime = 'AccAbProps_timestr_placeholder_lbl';
        $lblDUrl = 'AccProps_discoveryurl_placeholder_lbl';
        return [
            //                        GET   ERR CHK-INP
            'Missing account ID' => [ null, '', [] ],
            'Invalid account ID' => [ '123', 'No carddav account with ID 123', [] ],
            "Other user's account ID" => [ '101', 'No carddav account with ID 101', [] ],
            "Hidden Preset Account" => [ '45', 'Account ID 45 refers to a hidden account', [] ],
            "User-defined account with template addressbook" => [
                '42',
                null,
                [
                    //  name             val                          type      flags (RDP)  placeholder
                    [ 'accountid',       '42',                        'hidden',    '',       null ],
                    [ 'accountname',     'Test Account',              'text',      'R',      null ],
                    [ 'discovery_url',   'https://test.example.com/', 'text',      '',       $lblDUrl ],
                    [ 'username',        'johndoe',                   'text',      '',       null ],
                    [ 'password',        null,                        'password',  '',       null ],
                    [ 'rediscover_time', '02:00:00',                  'text',      'RP',     $lblTime ],
                    [ 'last_discovered', date("Y-m-d H:i:s", 1672825163), 'plain', '',       null ],
                    [ 'preemptive_basic_auth', '1',                   'checkbox',  '',       null ],
                    [ 'ssl_noverify',    '1',                         'checkbox',  '',       null ],
                    [ 'name',            '%N, %D',                    'text',      'R',      null ],
                    [ 'active',          '1',                         'checkbox',  '',       null ],
                    [ 'refresh_time',    '00:10:00',                  'text',      'RP',     $lblTime ],
                    [ 'use_categories',  '0',                         'radio',     '',       null ],
                    [ 'require_always_email', '1',                    'checkbox',  '',       null ],
                ]
            ],
            "New account" => [
                'new',
                null,
                [
                    //  name             val                          type        flags (RDP)  placeholder
                    [ 'accountid',       'new',                       'hidden',   '',          null ],
                    [ 'accountname',     '',                          'text',     'R',         null ],
                    [ 'discovery_url',   '',                          'text',     'R',         $lblDUrl ],
                    [ 'username',        '',                          'text',     '',          null ],
                    [ 'password',        null,                        'password', '',          null ],
                    [ 'rediscover_time', '24:00:00',                  'text',     'RP',        $lblTime ],
                    [ 'preemptive_basic_auth', '0',                   'checkbox', '',          null ],
                    [ 'ssl_noverify',    '0',                         'checkbox', '',          null ],
                    [ 'name',            '%N',                        'text',     'R',         null ],
                    [ 'active',          '1',                         'checkbox', '',          null ],
                    [ 'refresh_time',    '01:00:00',                  'text',     'RP',        $lblTime ],
                    [ 'use_categories',  '1',                         'radio',    '',          null ],
                    [ 'require_always_email', '0',                    'checkbox', '',          null ],
                ]
            ],
            "Visible Preset account without template addressbook" => [
                '44',
                null,
                [
                    //  name             val                          type        flags (RDP)  placeholder
                    [ 'accountid',       '44',                        'hidden',   '',          null ],
                    [ 'accountname',     'Preset Contacts',           'text',     'R',         null ],
                    [ 'discovery_url',   'https://carddav.example.com/', 'text',  '',          $lblDUrl ],
                    [ 'username',        'foodoo',                    'text',     'D',         null ],
                    [ 'password',        null,                        'password', 'D',         null ],
                    [ 'rediscover_time', '24:00:00',                  'text',     'RP',        $lblTime ],
                    [ 'last_discovered', 'DateTime_never_lbl',        'plain',    '',          null ],
                    [ 'name',            '%N (%D)',                   'text',     'D',         null ],
                    [ 'preemptive_basic_auth', '0',                   'checkbox', '',          null ],
                    [ 'ssl_noverify',    '0',                         'checkbox', 'D',         null ],
                    [ 'active',          '1',                         'checkbox', '',          null ],
                    [ 'refresh_time',    '00:30:00',                  'text',     'D',         $lblTime ],
                    [ 'use_categories',  '0',                         'radio',    '',          null ],
                    [ 'require_always_email', '0',                    'checkbox', 'D',         null ],
                ]
            ],
        ];
    }

    /**
     * Tests that the account details form is properly displayed.
     *
     * - For account ID=new, the form is shown with default values
     * - Fixed fields of a preset account are disabled
     * - Values from a template addressbook are shown if one exists
     *   - The template addressbook has precedence over the settings in the preset. For fixed settings, the template
     *     addressbook may be out of sync with the preset settings if the admin changed the value while the user was
     *     logged on. There is currently no handling for this and the outdated values will continued to be used.
     * - Default values for addressbook settings are shown if no template addressbook exists
     *   - For a preset, values included in the preset override the default values
     *
     * - Error cases:
     *   - Invalid account ID in GET parameters (error is logged, empty string is returned)
     *   - Account ID of different user in GET parameters (error is logged, empty string is returned)
     *
     * @param list<list{string,?string,string,string,?string}> $checkInputs
     * @dataProvider accountIdProvider
     */
    public function testAccountDetailsFormIsProperlyCreated(?string $getID, ?string $errMsg, array $checkInputs): void
    {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();
        if (is_string($getID)) {
            $rcStub->getInputs['accountid'] = $getID;
        }

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);

        $ui->actionAccDetails();
        if (is_null($getID)) {
            $logger->expectMessage('warning', 'no account ID found in parameters');
        } else {
            $this->assertContains('carddav.accountDetails', $rcStub->sentTemplates);
        }

        $html = $ui->tmplAccountDetails(['id' => 'accountdetails']);

        if (is_null($errMsg)) {
            $this->assertIsString($getID);
            $this->assertNotEmpty($html);

            $dom = new DOMDocument();
            $this->assertTrue($dom->loadHTML($html));

            // Check form fields exist and contain the expected values
            $this->checkInput($dom, $checkInputs);
        } else {
            $this->assertEmpty($html);

            if (strlen($errMsg) > 0) {
                $logger->expectMessage('error', $errMsg);
            }
        }
    }

    /**
     * GET: abook ID to set as GET parameter (null to not set one)
     * ERR: expected error message. Null if no error is expected, empty string if error case without error message
     *
     *                             GET      ERR     Check-inputs
     * @return array<string, list{?string, ?string, list<list{string,?string,string,string,?string}>}>
     */
    public function abookIdProvider(): array
    {
        $lblTime = 'AccAbProps_timestr_placeholder_lbl';
        return [
            //                        GET   ERR CHK-INP
            'Missing abook ID' => [ null, '', [] ],
            'Invalid abook ID' => [ '123', 'No carddav addressbook with ID 123', [] ],
            "Other user's addressbook ID" => [ '101', 'No carddav addressbook with ID 101', [] ],
            "Hidden Preset Addressbook" => [ '61', 'Account ID 45 refers to a hidden account', [] ],
            "User-defined addressbook" => [
                '42',
                null,
                [
                    //  name             val                          type         flags  placeholder
                    [ 'abookid',         '42',                        'hidden',    '',    null ],
                    [ 'name',            'Basic contacts',            'text',      'R',   null ],
                    [ 'url',             'https://test.example.com/books/johndoe/book42/', 'plain', '', null ],
                    [ 'refresh_time',    '00:06:00',                  'text',      'RP',  $lblTime ],
                    [ 'last_updated',    date("Y-m-d H:i:s", 1672825164), 'plain', '',    null ],
                    [ 'use_categories',  '1',                         'radio',     '',    null ],
                    [ 'srvname',         'Book 42 SrvName',           'plain',     '',    null ],
                    [ 'srvdesc',         "Hitchhiker's Guide",        'plain',     '',    null ],
                    [ 'require_always_email', '0',                    'checkbox',  '',    null ],
                ]
            ],
            "Preset extra addressbook with custom fixed fields" => [
                '51',
                null,
                [
                    //  name             val                          type         flags  placeholder
                    [ 'abookid',         '51',                        'hidden',    '',    null ],
                    [ 'name',            'Public readonly contacts',  'text',      'D',   null ],
                    [ 'url',             'https://carddav.example.com/shared/Public/', 'plain', '', null ],
                    [ 'refresh_time',    '01:00:00',                  'text',      'RP',  $lblTime ],
                    [ 'last_updated',    'DateTime_never_lbl',        'plain',     '',    null ],
                    [ 'use_categories',  '0',                         'radio',     '',    null ],
                    [ 'srvname',         null,                        'plain',     '',    null ],
                    [ 'srvdesc',         null,                        'plain',     '',    null ],
                    [ 'require_always_email', '1',                    'checkbox',  '',    null ],
                ]
            ],
        ];
    }

    /**
     * Tests that the addressbook details form is properly displayed.
     *
     * - Fixed fields of an addressbook belonging to a preset account are disabled
     *   - For an extra addressbook with specific fixed fields, these are properly considered
     * - For a preset, the addressbook DB settings may become out of sync with fixed settings in the config, if the
     *   config was changed by the admin while the user was still logged on. There is currently no special handling for
     *   this case and the DB values will be displayed.
     * - The server-side fields are displayed only when available. If querying from the server fails, they are not
     *   displayed at all.
     *
     * - Error cases:
     *   - Invalid addressbook ID in GET parameters (error is logged, empty string is returned)
     *   - Addressbook ID of different user in GET parameters (error is logged, empty string is returned)
     *   - Addressbook ID of addressbook belonging to a hidden preset in GET parameters (error is logged, empty string
     *     returned)
     *
     * @param list<list{string,?string,string,string,?string}> $checkInputs
     * @dataProvider abookIdProvider
     */
    public function testAddressbookDetailsFormIsProperlyCreated(
        ?string $getID,
        ?string $errMsg,
        array $checkInputs
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();
        if (is_string($getID)) {
            $rcStub->getInputs['abookid'] = $getID;
        }

        $this->setAddressbookStubs();

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);

        $ui->actionAbDetails();
        if (is_null($getID)) {
            $logger->expectMessage('warning', 'no addressbook ID found in parameters');
        } else {
            $this->assertContains('carddav.addressbookDetails', $rcStub->sentTemplates);
        }

        $html = $ui->tmplAddressbookDetails(['id' => 'addressbookdetails']);

        if (is_null($errMsg)) {
            $this->assertIsString($getID);
            $this->assertNotEmpty($html);

            $dom = new DOMDocument();
            $this->assertTrue($dom->loadHTML($html));

            // Check form fields exist and contain the expected values
            $this->checkInput($dom, $checkInputs);
        } else {
            $this->assertEmpty($html);

            if (strlen($errMsg) > 0) {
                $logger->expectMessage('error', $errMsg);
            }
        }
    }

    /**
     * @param list<list{string,?string,string,string,?string}> $checkInputs
     */
    private function checkInput(DOMDocument $dom, array $checkInputs): void
    {
        $xpath = new DOMXPath($dom);

        foreach ($checkInputs as $checkInput) {
            [ $iName, $iVal, $iType, $iFlags, $iPlaceholder ] = $checkInput;
            $iDisabled = (strpos($iFlags, 'D') !== false);
            $iRequired = (strpos($iFlags, 'R') !== false);
            $iPattern  = (strpos($iFlags, 'P') !== false);

            if ($iType === 'plain') {
                if (is_null($iVal)) { // null means the field should be omitted from the form
                    $span = $xpath->query("//tr[td/label[@for='$iName']]/td/span");
                    $this->assertInstanceOf(DOMNodeList::class, $span);
                    $this->assertCount(0, $span, "There is a form entry for empty plain attr $iName");
                } else {
                    $iNode = $this->getDomNode($xpath, "//tr[td/label[@for='$iName']]/td/span");
                    $this->assertSame($iVal, $iNode->textContent);
                }
            } elseif ($iType === 'radio') {
                $radioItems = $xpath->query("//input[@name='$iName']");
                $this->assertInstanceOf(DOMNodeList::class, $radioItems);
                $this->assertGreaterThan(1, count($radioItems));
                $valueItemFound = false;
                foreach ($radioItems as $radioItem) {
                    $this->assertInstanceOf(DOMNode::class, $radioItem);
                    $this->checkAttribute($radioItem, 'type', 'radio');

                    $this->assertInstanceOf(DOMNamedNodeMap::class, $radioItem->attributes);
                    $attrNode = $radioItem->attributes->getNamedItem('value');
                    $this->assertInstanceOf(DOMNode::class, $attrNode);
                    $this->assertIsString($attrNode->nodeValue);
                    if ($attrNode->nodeValue === $iVal) {
                        $valueItemFound = true;
                        $this->checkAttribute($radioItem, 'checked', 'checked');
                    } else {
                        $this->checkAttribute($radioItem, 'checked', null);
                    }
                    $this->checkAttribute($radioItem, 'disabled', $iDisabled ? 'disabled' : null);
                    $this->checkAttribute($radioItem, 'required', $iRequired ? 'required' : null);
                }
                $this->assertTrue($valueItemFound, "No radio button with the expected value exists for $iName");
            } elseif ($iType === 'checkbox') {
                $iNode = $this->getDomNode($xpath, "//input[@name='$iName']");
                $this->checkAttribute($iNode, 'value', '1');
                $this->checkAttribute($iNode, 'checked', $iVal ? 'checked' : null);
                $this->checkAttribute($iNode, 'type', $iType);
                $this->checkAttribute($iNode, 'disabled', $iDisabled ? 'disabled' : null);
                $this->checkAttribute($iNode, 'required', $iRequired ? 'required' : null);
            } else {
                $iNode = $this->getDomNode($xpath, "//input[@name='$iName']");
                $this->checkAttribute($iNode, 'value', $iVal);
                $this->checkAttribute($iNode, 'type', $iType);
                $this->checkAttribute($iNode, 'disabled', $iDisabled ? 'disabled' : null);
                $this->checkAttribute($iNode, 'required', $iRequired ? 'required' : null);
                $this->checkAttribute($iNode, 'pattern', $iPattern ? '' : null, 'exists');
                $this->checkAttribute($iNode, 'placeholder', $iPlaceholder);
            }
        }
    }

    /**
     * @psalm-param 'equals'|'contains'|'containsnot'|'exists' $matchType
     * @param ?string $val Expected value of the attribute. If null, the node must not have the given attribute.
     */
    private function checkAttribute(DOMNode $node, string $attr, ?string $val, string $matchType = 'equals'): void
    {
        // If available, get the name attribute to show better error messages
        $iName = '<no name>';
        if (
            is_a($node->attributes, DOMNamedNodeMap::class) &&
            !is_null($nameNode = $node->attributes->getNamedItem('name'))
        ) {
            $iName = $nameNode->nodeValue;
        }

        if (is_null($val)) {
            // Check that the node does not have the given attribute; this is met if the node has no attributes at all,
            // or if the given attribute is not one of the existing attributes
            $this->assertFalse(
                is_a($node->attributes, DOMNamedNodeMap::class) && !is_null($node->attributes->getNamedItem($attr)),
                "$iName: Attribute $attr not expected to exist, but does exist"
            );
            return;
        }

        $this->assertInstanceOf(DOMNamedNodeMap::class, $node->attributes);
        $attrNode = $node->attributes->getNamedItem($attr);
        $this->assertInstanceOf(DOMNode::class, $attrNode, "$iName: expected attribute $attr not present");

        if ($matchType === 'equals') {
            $this->assertSame($val, $attrNode->nodeValue, "$iName: expected value of $attr mismatches");
        } elseif (strpos($matchType, 'contains') === 0) {
            // contains match
            $vals = explode(' ', $attrNode->nodeValue ?? '');
            if ($matchType === 'contains') {
                $this->assertContains($val, $vals, "$iName: value of $attr does not contain search value");
            } else {
                $this->assertNotContains($val, $vals, "$iName: value of $attr contains search value");
            }
        }
    }

    /**
     * Checks the name displayed in the given list item node for an account or addressbook matches the expectation.
     *
     * The name is nested inside a span element, which is nested inside an a element inside the given li.
     */
    private function checkAccAbName(DOMXPath $xpath, DOMNode $li, string $name): void
    {
        // Check name, which is stored in a span element
        $span = $this->getDomNode($xpath, "a/span", $li);
        $this->assertSame($name, $span->textContent);
    }

    /**
     * Checks the existence of exactly one DOMNode with the given XPath query and returns that node.
     *
     * @return DOMNode The DOMNode; if it does not exist, an assertion in this function will fail and not return.
     */
    private function getDomNode(DOMXPath $xpath, string $xpathquery, ?DOMNode $context = null): DOMNode
    {
            $item = $xpath->query($xpathquery, $context);
            $this->assertInstanceOf(DOMNodeList::class, $item);
            $this->assertCount(1, $item, "Not exactly one node returned for $xpathquery");
            $item = $item->item(0);
            $this->assertInstanceOf(DOMNode::class, $item);
            return $item;
    }

    private function setAddressbookStubs(): void
    {
        TestInfrastructure::$infra->webDavResources = [
            'https://test.example.com/books/johndoe/book42/' => $this->makeAbookCollStub(
                'Book 42 SrvName',
                'https://test.example.com/books/johndoe/book42/',
                "Hitchhiker's Guide"
            ),
            'https://test.example.com/books/johndoe/book43/' => $this->makeAbookCollStub(
                'Book 43 SrvName',
                'https://test.example.com/books/johndoe/book43/',
                null
            ),
            'https://carddav.example.com/books/johndoe/book44/' => $this->makeAbookCollStub(
                null,
                'https://carddav.example.com/books/johndoe/book44/',
                null
            ),
            "https://carddav.example.com/shared/Public/" => $this->createStub(WebDavResource::class),
            "https://admonly.example.com/books/johndoe/book61/" => new \Exception('hidden preset was queried'),
        ];
    }

    private function setDiscoveryStub(int $numAbooks, string $discUrl, string $username): void
    {
        // create some test addressbooks to be discovered
        $abookObjs = [];
        for ($i = 0; $i < $numAbooks; ++$i) {
            $abookUrl = $discUrl . $username . "/addressbooks/book$i";
            $abookStub = $this->makeAbookCollStub("Book $i", $abookUrl, "Desc $i");
            $abookObjs[] = $abookStub;
            TestInfrastructure::$infra->webDavResources[$abookUrl] = $abookStub;
        }

        // create a Discovery mock that "discovers" our test addressbooks
        $discovery = $this->createMock(Discovery::class);
        $discovery->expects($this->once())
                  ->method("discoverAddressbooks")
                  ->will($this->returnValue($abookObjs));
        TestInfrastructure::$infra->discovery = $discovery;
    }

    /**
     * Creates an AddressbookCollection stub that implements getUri() and getName().
     */
    private function makeAbookCollStub(?string $name, string $url, ?string $desc): AddressbookCollection
    {
        $davobj = $this->createStub(AddressbookCollection::class);
        $urlComp = explode('/', rtrim($url, '/'));
        $baseName = $urlComp[count($urlComp) - 1];
        $davobj->method('getName')->will($this->returnValue($name ?? $baseName));
        $davobj->method('getBasename')->will($this->returnValue($baseName));
        $davobj->method('getDisplayname')->will($this->returnValue($name));
        $davobj->method('getDescription')->will($this->returnValue($desc));
        $davobj->method('getUri')->will($this->returnValue($url));
        return $davobj;
    }

    /**
     * @return array<string, list{array<string,string>,?list{PsrLogLevel,string},?string}>
     */
    public function accountSaveFormDataProvider(): array
    {
        $basicData = [
            'accountname' => 'Updated account name',
            'discovery_url' => 'http://updated.discovery.url/',
            'username' => 'upduser',
            'password' => '', // normally the password will not be set and must be ignored in this case
            'rediscover_time' => '5:6:7',
            // template addressbook settings
            'name' => 'Updated name %N - %D', // placeholders must not be replaced when saving
            // active would be omitted when set to off
            'refresh_time' => '0:42',
            'use_categories' => '1',
        ];

        $epfx = 'Error saving account preferences:';
        return [
            'Missing account ID' => [ [], ['warning', 'no account ID found in parameters'], null ],
            'Invalid account ID' => [
                ['accountid' => '123'] + $basicData,
                ['error', "$epfx No carddav account with ID 123"],
                null,
            ],
            "Other user's account ID" => [
                ['accountid' => '101'] + $basicData,
                ['error', "$epfx No carddav account with ID 101"],
                null,
            ],
            'Hidden Preset Account'   => [
                ['accountid' => '45'] + $basicData,
                ['error', "$epfx Account ID 45 refers to a hidden account"],
                null,
            ],
            'Invalid radio button value'   => [
                ['accountid' => '42', 'use_categories' => '2'] + $basicData,
                ['error', "$epfx Invalid value 2 POSTed for use_categories"],
                null,
            ],
            "User-defined account with template addressbook (password not changed)" => [
                // last_discovered must be ignored in the input
                ['accountid' => '42', 'last_discovered' => '123', 'ssl_noverify' => '1'] + $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AccSave-udefAcc.json'
            ],
            "User-defined account (password changed), template addressbook created" => [
                ['accountid' => '43', 'password' => 'new pass', 'use_categories' => '0'] + $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AccSave-udefAcc-pwChange.json'
            ],
            "Preset account with fixed fields submitted, template abook created" => [
                // template must be ignored in the input
                ['accountid' => '44', 'password' => 'foo', 'active' => '1', 'template' => '0'] + $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AccSave-presetAccFixedFields.json'
            ],
        ];
    }

    /**
     * Tests that an existing account is properly saved when the form is submitted (plugin.carddav.AccSave).
     *
     * - Sent values are properly saved
     *   - Both on / off value of checkboxes (toggles) are correctly evaluated (off = value not sent)
     *   - Radio button values are properly evaluated
     *   - Improperly formatted values (e.g. time strings) cause an error and the account is not saved
     * - For a preset account, fixed fields are not overwritten even if part of the form
     * - The template addressbook is created / updated
     *
     * - Error cases:
     *   - No account ID in POST parameters (error is logged, no action performed)
     *   - Invalid account ID in POST parameters (error is logged, error message sent to client, no action performed)
     *   - Account ID of different user in POST parameters (error is logged, error message sent to client, no action
     *     performed)
     *   - Account ID of addressbook belonging to a hidden preset in POST parameters (error is logged, error message
     *     sent to client, no action performed)
     *
     * @dataProvider accountSaveFormDataProvider
     * @param array<string,string> $postData
     * @param ?list{PsrLogLevel,string} $errMsgExp
     */
    public function testAccountIsProperlySaved(
        array $postData,
        ?array $errMsgExp,
        ?string $expDbFile
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();
        $rcStub->postInputs = $postData;

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $ui->actionAccSave();

        if (is_null($errMsgExp)) {
            $this->assertIsString($expDbFile, 'for non-error cases, we need an expected database file to compare with');
            $this->assertTrue($rcStub->checkShownMessages('confirmation', 'AccAbSave_msg_ok'));
        } else {
            // data must not be modified
            $expDbFile = 'tests/Unit/data/uiTest/db.json';
            $logger->expectMessage($errMsgExp[0], $errMsgExp[1]);
            if ($errMsgExp[0] === 'error') {
                $this->assertTrue($rcStub->checkShownMessages('error', "AccAbSave_msg_fail"));
            }
        }

        $dbAfter = new JsonDatabase([$expDbFile]);
        $dbAfter->compareTables('accounts', $this->db);
        $dbAfter->compareTables('addressbooks', $this->db);
    }

    /**
     * @return array<string, list{array<string,string>,?list{PsrLogLevel,string},?string}>
     */
    public function abookSaveFormDataProvider(): array
    {
        $basicData = [
            'name' => 'Updated name %N - %D', // placeholders are not replaced when saving addressbook
            'refresh_time' => '0:42',
            'use_categories' => '0',
        ];

        $epfx = 'Error saving addressbook preferences:';
        return [
            'Missing addressbook ID' => [ [], ['warning', 'no addressbook ID found in parameters'], null ],
            'Invalid addressbook ID' => [
                ['abookid' => '123'] + $basicData,
                ['error', "$epfx No carddav addressbook with ID 123"],
                null,
            ],
            "Other user's addressbook ID" => [
                ['abookid' => '101'] + $basicData,
                ['error', "$epfx No carddav addressbook with ID 101"],
                null,
            ],
            'Hidden Preset Addressbook'   => [
                ['abookid' => '61'] + $basicData,
                ['error', "$epfx Account ID 45 refers to a hidden account"],
                null,
            ],
            'Invalid radio button value'   => [
                ['abookid' => '42', 'use_categories' => '2'] + $basicData,
                ['error', "$epfx Invalid value 2 POSTed for use_categories"],
                null,
            ],
            "Addressbook of user-defined account" => [
                // discovered, url and active must be ignored in the input
                [
                    'abookid' => '42', 'require_always_email' => '1',
                    'url' => 'http://new.url/x', 'active' => '0', 'discovered' => '0'
                ] + $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AbSave-udefAcc.json'
            ],
            "Preset addressbook with fixed fields submitted" => [
                // name is fixed and must not be changed; refresh_time is not fixed for the extra addressbook
                ['abookid' => '51'] + $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AbSave-presetAccFixedFields.json'
            ],
            "Preset addressbook with only non-fixed fields submitted (normal case)" => [
                ['abookid' => '51', 'refresh_time' => '0:15', 'use_categories' => '1' ],
                null,
                'tests/Unit/data/uiTest/dbExp-AbSave-presetAcc.json'
            ],
        ];
    }

    /**
     * Tests that an addressbook is properly saved when the form is submitted (plugin.carddav.AbSave).
     *
     * - Sent values are properly saved
     *   - Radio button values are properly evaluated, incl. invalid values
     *   - Improperly formatted values (e.g. time strings) cause an error and the account is not saved
     * - For a preset account, fixed fields are not overwritten even if part of the form
     *
     * - Error cases:
     *   - No addressbook ID in POST parameters (error is logged, no action performed)
     *   - Invalid addressbook ID in POST parameters (error logged, error message sent to client, no action performed)
     *   - Addressbook ID of different user in POST parameters (error is logged, error message sent to client, no action
     *     performed)
     *   - Addressbook ID belonging to a hidden preset in POST parameters (error is logged, error message sent to
     *     client, no action performed)
     *
     * @dataProvider abookSaveFormDataProvider
     * @param array<string,string> $postData
     * @param ?list{PsrLogLevel,string} $errMsgExp
     */
    public function testAddressbookIsProperlySaved(
        array $postData,
        ?array $errMsgExp,
        ?string $expDbFile
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();
        $rcStub->postInputs = $postData;

        $this->setAddressbookStubs();

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $ui->actionAbSave();

        if (is_null($errMsgExp)) {
            $this->assertIsString($expDbFile, 'for non-error cases, we need an expected database file to compare with');
            $this->assertTrue($rcStub->checkShownMessages('confirmation', 'AccAbSave_msg_ok'));
        } else {
            // data must not be modified
            $expDbFile = 'tests/Unit/data/uiTest/db.json';
            $logger->expectMessage($errMsgExp[0], $errMsgExp[1]);
            if ($errMsgExp[0] === 'error') {
                $this->assertTrue($rcStub->checkShownMessages('error', "AccAbSave_msg_fail"));
            }
        }

        $dbAfter = new JsonDatabase([$expDbFile]);
        $dbAfter->compareTables('accounts', $this->db);
        $dbAfter->compareTables('addressbooks', $this->db);
    }

    /**
     * @return array<string, list{array<string,string>,?list{PsrLogLevel,string},?string,int}>
     */
    public function accountAddFormDataProvider(): array
    {
        $basicData = [
            'accountname' => 'New account',
            'discovery_url' => 'http://cdav.example.com/',
            'username' => 'user',
            'password' => 'pw',
            'rediscover_time' => '5:6:7',
            // template addressbook settings
            'name' => 'Name template %N - %D', // placeholders must not be replaced when saving
            // active would be omitted when set to off
            'refresh_time' => '0:30',
            'use_categories' => '1',
        ];

        $epfx = 'Error creating CardDAV account:';
        return [
            'Missing Discovery URL'   => [
                ['accountname' => 'New account'],
                ['error', "$epfx Cannot discover addressbooks for an account lacking a discovery URI"],
                null,
                0
            ],
            "Two addressbooks discovered (added inactive, with categories)" => [
                [ 'require_always_email' => '1' ] + $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AccAdd-2books.json',
                2
            ],
            "One addressbook discovered (added active, no categories)" => [
                [ 'active' => '1', 'use_categories' => '0' ] + $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AccAdd-1book.json',
                1
            ],
            "No addressbook discovered" => [
                $basicData,
                null,
                'tests/Unit/data/uiTest/dbExp-AccAdd-0books.json',
                0
            ],
        ];
    }

    /**
     * Tests that a new account is properly created when the corresponding form is submitted.
     *
     * - Addressbooks returned by discovery are properly added incl. template addressbook
     * - Discovery returns no addressbooks, account without addressbooks is added (only template addressbook)
     *
     * Error cases:
     *  - Discovery URL not provided
     *
     * @dataProvider accountAddFormDataProvider
     * @param array<string,string> $postData
     * @param ?list{PsrLogLevel,string} $errMsgExp
     */
    public function testNewAccountIsProperlyCreated(
        array $postData,
        ?array $errMsgExp,
        ?string $expDbFile,
        int $numAbooks
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();
        $rcStub->postInputs = $postData;

        $this->setAddressbookStubs();

        if (is_null($errMsgExp)) {
            $this->setDiscoveryStub(
                $numAbooks,
                $postData['discovery_url'] ?? '',
                $postData['username']
            );
        }

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $ui->actionAccAdd();

        if (is_null($errMsgExp)) {
            $this->assertIsString($expDbFile, 'for non-error cases, we need an expected database file to compare with');
            $this->assertTrue($rcStub->checkShownMessages('confirmation', 'AccAdd_msg_ok'));
        } else {
            // data must not be modified
            $expDbFile = 'tests/Unit/data/uiTest/db.json';
            $logger->expectMessage($errMsgExp[0], $errMsgExp[1]);
            if ($errMsgExp[0] === 'error') {
                $this->assertTrue($rcStub->checkShownMessages('error', "AccAbSave_msg_fail"));
            }
        }

        if (is_null($errMsgExp)) {
            $this->fixTimestampCol(['accountname' => 'New account'], 'accounts', 'last_discovered', '0');
        }

        $dbAfter = new JsonDatabase([$expDbFile]);
        $dbAfter->compareTables('accounts', $this->db);
        $dbAfter->compareTables('addressbooks', $this->db);
    }

    /**
     * @return array<string, list{array<string,string>,?list{PsrLogLevel,string},?string,?array}>
     */
    public function abookToggleFormDataProvider(): array
    {
        $epfx = 'Failure to toggle addressbook activation:';
        return [
            'Missing addressbook ID' => [
                ['active' => '1'],
                ['warning', 'invoked without required HTTP POST inputs'],
                null, // expDbFile
                null, // expClientCommandArgs
            ],
            'Missing active setting ID' => [
                ['abookid' => '42'],
                ['warning', 'invoked without required HTTP POST inputs'],
                null, // expDbFile
                null, // expClientCommandArgs
            ],
            'Invalid addressbook ID' => [
                ['abookid' => '123', 'active' => '1'],
                ['error', "$epfx No carddav addressbook with ID 123"],
                null, // expDbFile
                null, // expClientCommandArgs - no command expected because ID is invalid
            ],
            "Other user's addressbook ID" => [
                ['abookid' => '101', 'active' => '0'],
                ['error', "$epfx No carddav addressbook with ID 101"],
                null, // expDbFile
                null, // expClientCommandArgs - no command expected because this addressbook should not appear in UI
            ],
            'Addressbook of hidden preset account' => [
                ['abookid' => '61', 'active' => '0'],
                ['error', "$epfx Account ID 45 refers to a hidden account"],
                null, // expDbFile
                null, // expClientCommandArgs - no command expected because this addressbook should not appear in UI
            ],
            'Active addressbook where active attribute is fixed (try to deactivate)' => [
                ['abookid' => '51', 'active' => '0'],
                ['error', "$epfx active is a fixed setting for addressbook 51"],
                null, // expDbFile
                ['51', true], // expClientCommandArgs - UI toggle changed to wrong state and must be reset
            ],
            'Active addressbook where active attribute is fixed (try to activate)' => [
                ['abookid' => '51', 'active' => '1'],
                ['error', "$epfx active is a fixed setting for addressbook 51"],
                null, // expDbFile
                ['51', true], // expClientCommandArgs - UI toggle changed to wrong state and must be reset
            ],
            'Deactivate active addressbook' => [
                ['abookid' => '42', 'active' => '0'],
                null,
                'tests/Unit/data/uiTest/dbExp-AbToggleActive-DeactActive.json',
                null, // expClientCommandArgs
            ],
            'Deactivate inactive addressbook' => [
                ['abookid' => '44', 'active' => '0'],
                null,
                'tests/Unit/data/uiTest/db.json',
                null, // expClientCommandArgs
            ],
            'Activate inactive addressbook' => [
                ['abookid' => '44', 'active' => '1'],
                null,
                'tests/Unit/data/uiTest/dbExp-AbToggleActive-ActInactive.json',
                null, // expClientCommandArgs
            ],
            'Activate active addressbook' => [
                ['abookid' => '42', 'active' => '1'],
                null,
                'tests/Unit/data/uiTest/db.json',
                null, // expClientCommandArgs
            ],
        ];
    }

    /**
     * Tests that the AbToggleActive action invoked works properly.
     *
     * - Addressbook is activated (on both active and inactive addressbook)
     * - Addressbook is deactivated (on both active and inactive addressbook)
     *
     * Error cases:
     *   - No addressbook ID in parameters (warning is logged)
     *   - No active value in parameters (warning is logged)
     *
     *   - Invalid addressbook ID in parameters (error is logged, error shown to client)
     *   - Addressbook ID of different user in parameters (error is logged, error shown to client)
     *   - Addressbook ID belonging to a hidden preset in parameters (error is logged, error shown to client)
     *
     *   - Addressbook ID of an addressbook where the active attribute is fixed is sent (error is logged, error shown to
     *     client, toggle is reset on client)
     *
     * @dataProvider abookToggleFormDataProvider
     * @param array<string,string> $postData
     * @param ?list{PsrLogLevel,string} $errMsgExp
     */
    public function testAddressbookToggleActiveWorksProperly(
        array $postData,
        ?array $errMsgExp,
        ?string $expDbFile,
        ?array $expClientCommandArgs
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();
        $rcStub->postInputs = $postData;

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $ui->actionAbToggleActive();

        $suffix = ($postData['active'] ?? '') === '0' ? '_de' : '';

        if (is_null($errMsgExp)) {
            $this->assertIsString($expDbFile, 'for non-error cases, we need an expected database file to compare with');
            $this->assertTrue($rcStub->checkShownMessages('confirmation', "AbToggleActive_msg_ok$suffix"));
        } else {
            // data must not be modified
            $expDbFile = 'tests/Unit/data/uiTest/db.json';
            $logger->expectMessage($errMsgExp[0], $errMsgExp[1]);
            if ($errMsgExp[0] === 'error') {
                $this->assertArrayHasKey('abookid', $postData);
                $this->assertTrue($rcStub->checkShownMessages('error', "AbToggleActive_msg_fail$suffix"));
            }
        }

        if (is_null($expClientCommandArgs)) {
            $this->assertCount(0, $rcStub->sentCommands);
        } else {
            $this->assertCount(1, $rcStub->sentCommands);
            $this->assertSame('carddav_AbResetActive', $rcStub->sentCommands[0][0]);
            $this->assertSame($expClientCommandArgs, $rcStub->sentCommands[0][1]);
        }
        $dbAfter = new JsonDatabase([$expDbFile]);
        $dbAfter->compareTables('accounts', $this->db);
        $dbAfter->compareTables('addressbooks', $this->db);
    }

    /**
     * @return array<string, list{?string,?list{PsrLogLevel,string},?string}>
     */
    public function accountDeleteFormDataProvider(): array
    {
        $epfx = 'Error removing account:';
        return [
            'Missing account ID' => [ null, ['warning', 'no account ID found in parameters'], null ],
            'Invalid account ID' => [ '123', ['error', "$epfx No carddav account with ID 123"], null ],
            "Other user's account ID" => [ '101', ['error', "$epfx No carddav account with ID 101"], null ],
            'Hidden Preset Account' => [ '45', ['error', "$epfx Account ID 45 refers to a hidden account"], null ],
            'Preset Account' => [ '44', ['error', "$epfx Only the administrator can remove preset accounts"], null ],
            "User-defined account" => [ '42', null, 'tests/Unit/data/uiTest/dbExp-AccRm-udefAcc.json' ],
        ];
    }

    /**
     * Tests that an existing account is properly deleted when requested from UI.
     *
     * - User-defined account incl. all addressbooks is removed
     *
     * - Error cases:
     *   - No account ID in parameters (error is logged, no action performed)
     *   - Invalid account ID in parameters (error is logged, error message sent to client, no action performed)
     *   - Account ID of different user in parameters (error is logged, error message sent to client, no action
     *     performed)
     *   - Account ID of addressbook belonging to a preset in parameters (error is logged, error message sent to
     *     client, no action performed)
     *
     * @dataProvider accountDeleteFormDataProvider
     * @param ?list{PsrLogLevel,string} $errMsgExp
     */
    public function testAccountIsProperlyRemoved(
        ?string $accountId,
        ?array $errMsgExp,
        ?string $expDbFile
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();

        if (is_string($accountId)) {
            $rcStub->postInputs['accountid'] = $accountId;
        }

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $ui->actionAccRm();

        if (is_null($errMsgExp)) {
            $this->assertIsString($expDbFile, 'for non-error cases, we need an expected database file to compare with');
            $this->assertTrue($rcStub->checkShownMessages('confirmation', 'AccRm_msg_ok'));

            $this->assertCount(1, $rcStub->sentCommands);
            $this->assertSame('carddav_RemoveListElem', $rcStub->sentCommands[0][0]);
            $this->assertSame([$accountId], $rcStub->sentCommands[0][1]);
        } else {
            $this->assertCount(0, $rcStub->sentCommands);

            // data must not be modified
            $expDbFile = 'tests/Unit/data/uiTest/db.json';
            $logger->expectMessage($errMsgExp[0], $errMsgExp[1]);
            if ($errMsgExp[0] === 'error') {
                $this->assertTrue($rcStub->checkShownMessages('error', "AccRm_msg_fail"));
            }
        }

        $dbAfter = new JsonDatabase([$expDbFile]);
        $dbAfter->compareTables('accounts', $this->db);
        $dbAfter->compareTables('addressbooks', $this->db);
    }

    /**
     * @return array<string, list{?string,?list{PsrLogLevel,string},?string}>
     */
    public function accountRediscoverFormDataProvider(): array
    {
        $epfx = 'Error in account rediscovery:';
        return [
            'Missing account ID' => [ null, ['warning', 'no account ID found in parameters'], null ],
            'Invalid account ID' => [ '123', ['error', "$epfx No carddav account with ID 123"], null ],
            "Other user's account ID" => [ '101', ['error', "$epfx No carddav account with ID 101"], null ],
            'Hidden Preset Account' => [ '45', ['error', "$epfx Account ID 45 refers to a hidden account"], null ],
            "User-defined account" => [ '42', null, 'tests/Unit/data/uiTest/dbExp-AccRedisc-udefAcc.json' ],
        ];
    }

    /**
     * Tests that an existing account is properly rediscovered when requested from UI
     *
     * - Account with discovery_url is rediscovered
     *
     * - Error cases:
     *   - No account ID in parameters (error is logged, no action performed)
     *   - Invalid account ID in parameters (error is logged, error message sent to client, no action performed)
     *   - Account ID of different user in parameters (error is logged, error message sent to client, no action
     *     performed)
     *   - Account ID of addressbook belonging to a hidden preset in parameters (error is logged, error message sent to
     *     client, no action performed)
     *   - Rediscovery for account without rediscovery URL is requested
     *
     * @dataProvider accountRediscoverFormDataProvider
     * @param ?list{PsrLogLevel,string} $errMsgExp
     */
    public function testAccountIsProperlyRediscovered(
        ?string $accountId,
        ?array $errMsgExp,
        ?string $expDbFile
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();

        if (is_string($accountId)) {
            $rcStub->postInputs['accountid'] = $accountId;
        }

        if (is_string($expDbFile)) {
            $this->setDiscoveryStub(2, 'https://test.example.com/', 'johndoe');
        }

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $ui->actionAccRedisc();

        if (is_null($errMsgExp)) {
            $this->assertIsString($expDbFile, 'for non-error cases, we need an expected database file to compare with');
            $this->assertTrue($rcStub->checkShownMessages('confirmation', 'AccRedisc_msg_ok'));

            $this->assertCount(2, $rcStub->sentCommands);
            $this->assertSame('carddav_RemoveListElem', $rcStub->sentCommands[0][0]);
            $this->assertEqualsCanonicalizing([$accountId, ['42','43','44']], $rcStub->sentCommands[0][1]);
            $this->assertSame('carddav_InsertListElem', $rcStub->sentCommands[1][0]);
            $this->assertCount(1, $rcStub->sentCommands[1][1]);
            $this->assertIsArray($rcStub->sentCommands[1][1][0]);
            $this->assertCount(2, $rcStub->sentCommands[1][1][0]); // two inserted expected
            // InsertListElem args not checked in detail because of complexity
        } else {
            $this->assertCount(0, $rcStub->sentCommands);

            // data must not be modified
            $expDbFile = 'tests/Unit/data/uiTest/db.json';
            $logger->expectMessage($errMsgExp[0], $errMsgExp[1]);
            if ($errMsgExp[0] === 'error') {
                $this->assertTrue($rcStub->checkShownMessages('error', "AccRedisc_msg_fail"));
            }
        }

        if (is_null($errMsgExp)) {
            $this->assertIsString($accountId);
            $this->fixTimestampCol($accountId, 'accounts', 'last_discovered', '5555');
        }

        $dbAfter = new JsonDatabase([$expDbFile]);
        $dbAfter->compareTables('accounts', $this->db);
        $dbAfter->compareTables('addressbooks', $this->db);
    }

    /**
     * @return array<string, list{array<string,string>,?list{PsrLogLevel,string},?string}>
     */
    public function abookResyncFormDataProvider(): array
    {
        $epfxS = 'Failed to sync (AbSync) addressbook:';
        $epfxC = 'Failed to sync (AbClrCache) addressbook:';
        return [
            'Missing addressbook ID' => [
                ['synctype' => 'AbSync'],
                ['warning', 'missing or unexpected values for HTTP POST parameters'],
                null
            ],
            'Missing sync type' => [
                ['abookid' => '42'],
                ['warning', 'missing or unexpected values for HTTP POST parameters'],
                null
            ],
            'Invalid sync type' => [
                ['abookid' => '42', 'synctype' => 'foo'],
                ['warning', 'missing or unexpected values for HTTP POST parameters'],
                null
            ],

            'Invalid addressbook ID' => [
                ['abookid' => '123', 'synctype' => 'AbSync'],
                ['error', "$epfxS No carddav addressbook with ID 123"],
                null
            ],
            "Other user's addressbook ID" => [
                ['abookid' => '101', 'synctype' => 'AbClrCache'],
                ['error', "$epfxC No carddav addressbook with ID 101"],
                null
            ],
            'Hidden Preset Account' => [
                ['abookid' => '61', 'synctype' => 'AbSync'],
                ['error', "$epfxS Account ID 45 refers to a hidden account"],
                null
            ],

            "User-defined addressbook resync" => [
                ['abookid' => '42', 'synctype' => 'AbSync'],
                null,
                'tests/Unit/data/uiTest/dbExp-AbSync-udefAcc.json'
            ],

            "User-defined addressbook clear cache" => [
                ['abookid' => '42', 'synctype' => 'AbClrCache'],
                null,
                'tests/Unit/data/uiTest/dbExp-AbClrCache-udefAcc.json'
            ],
        ];
    }

    /**
     * Tests that an addressbook is properly resynced / cache cleared when requested from UI
     *
     * - Valid addressbook resynced
     * - Cache clear on valid addressbook
     *
     * - Error cases:
     *   - No abook ID in parameters (warning is logged, no action performed)
     *   - No sync type in parameters (warning is logged, no action performed)
     *   - Invalid sync type (warning is logged, no action performed)
     *   - Invalid abook ID in parameters (error is logged, error message sent to client, no action performed)
     *   - Abook ID of different user in parameters (error is logged, error message sent to client, no action
     *     performed)
     *   - ID of addressbook belonging to a hidden preset in parameters (error is logged, error message sent to
     *     client, no action performed)
     *
     * @dataProvider AbookResyncFormDataProvider
     * @param array<string,string> $postData
     * @param ?list{PsrLogLevel,string} $errMsgExp
     */
    public function testAddressbookIsProperlyResynced(
        array $postData,
        ?array $errMsgExp,
        ?string $expDbFile
    ): void {
        $this->db = new JsonDatabase(['tests/Unit/data/uiTest/db.json']);
        TestInfrastructure::init($this->db, 'tests/Unit/data/uiTest/config.inc.php');
        $logger = TestInfrastructure::logger();
        $infra = TestInfrastructure::$infra;
        $rcStub = $infra->rcTestAdapter();

        $rcStub->postInputs = $postData;
        $syncType = $postData['synctype'] ?? '';

        $this->setAddressbookStubs();

        $sync = $this->createMock(Sync::class);
        $infra->sync = $sync;

        if (is_null($errMsgExp) && $syncType === 'AbSync') {
            $this->assertSame('42', $postData['abookid'], 'Currently test is hardcoded for abook 42');
            $this->assertIsArray($infra->webDavResources);
            $this->assertArrayHasKey('https://test.example.com/books/johndoe/book42/', $infra->webDavResources);
            $abookObj = $infra->webDavResources['https://test.example.com/books/johndoe/book42/'];
            $sync->expects($this->once())
                 ->method('synchronize')
                 ->with($this->equalTo($abookObj), $this->anything(), $this->anything(), $this->equalTo('sync@3600'))
                 ->will($this->returnValue('sync@resynctime'));
        } else {
            $sync->expects($this->never())->method('synchronize');
        }

        $abMgr = new AddressbookManager();
        $ui = new UI($abMgr);
        $ui->actionAbSync();

        if (is_null($errMsgExp)) {
            $this->assertIsString($expDbFile, 'for non-error cases, we need an expected database file to compare with');
            $this->assertTrue($rcStub->checkShownMessages('notice', "{$syncType}_msg_ok"));

            $this->assertCount(1, $rcStub->sentCommands);
            $this->assertSame('carddav_UpdateForm', $rcStub->sentCommands[0][0]);
            $this->assertCount(1, $rcStub->sentCommands[0][1]);
            $this->assertIsArray($rcStub->sentCommands[0][1][0]);
            $this->assertArrayHasKey('last_updated', $rcStub->sentCommands[0][1][0]);
        } else {
            $this->assertCount(0, $rcStub->sentCommands);

            // data must not be modified
            $expDbFile = 'tests/Unit/data/uiTest/db.json';
            $logger->expectMessage($errMsgExp[0], $errMsgExp[1]);
            if ($errMsgExp[0] === 'error') {
                $this->assertTrue($rcStub->checkShownMessages('error', "{$syncType}_msg_fail"));
            }
        }

        if (is_null($errMsgExp) && (($postData['synctype'] ?? '') === 'AbSync')) {
            $this->assertArrayHasKey('abookid', $postData);
            // Before comparing, we need to fix the last_updated timestamp as it depends on the current time
            $this->fixTimestampCol($postData['abookid'], 'addressbooks', 'last_updated', '4242');
        }

        $dbAfter = new JsonDatabase([$expDbFile]);
        $dbAfter->compareTables('accounts', $this->db);
        $dbAfter->compareTables('addressbooks', $this->db);
        $dbAfter->compareTables('contacts', $this->db);
        $dbAfter->compareTables('groups', $this->db);
        $dbAfter->compareTables('group_user', $this->db);
        $dbAfter->compareTables('xsubtypes', $this->db);
    }

    /**
     * @param DbConditions $conditions Selects the row to lookup.
     */
    private function fixTimestampCol($conditions, string $tbl, string $col, string $val): void
    {
        $row = $this->db->lookup($conditions, [], $tbl);
        $this->assertArrayHasKey('id', $row);
        $this->assertIsString($row['id']);
        $this->assertArrayHasKey($col, $row);
        $this->assertLessThan(2, time() - intval($row[$col])); // two seconds grace period
        $this->db->update($row['id'], [ $col ], [ $val ], $tbl);
    }
}

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