Using the WordPress functional test module 02

The first two passing functional tests.

The short story so far

I’m developing a multi-site based language plugin called “Tongues”; I’ve gone over its meager features in a previous post.
The purpose of the enterprise is to allow me to use the functional tests dedicated WordPress module part of the wp-browser package in a real project to explore its capabilities and limits.
The first functional tests I’ve set out to write and be able to “pass” are meant to test out a super-admin user option setting flow: the user, provided her capability to manage network options, should be able to assign some or all the blogs on the network a language code.
The language code will update the lang_id column in the WordPress installation database creating thus a map that binds a blog_id to a lang_id; the lang_id parameter, an integer value in the database table, will then be mapped to a real language code like “en_US”.

On GitHub

The code I present in this post is on GitHub tagged 0.0.2.

Tapping into the REST API infrastructure

Since version 4.4. WordPress integrates the REST API infrastructure in turn allowing theme and plugin developers to register and handle their own routes and endpoints under the /wp-json root (that can change but it’s a good assumption).
The use case above describes a network super-admin user setting and saving some options through a standard WordPress options page: how could the REST API infrastructure come into play?
Since I’m developing from scratch I’d like to be able to implement a back-end uncoupled from the front-end; in this particular interaction case the “back-end” will be the code processing and saving the settings and the “front-end” will be the code allowing the user to assemble the data for an HTTP request.
In code terms this means that the page form will specify, as action attribute, a REST API address, specifically /wp-json/tongues/v1/network-settings.
This will allow me in the future, should I so be inclined, to manage settings with AJAX calls from someplace that’s not the WordPress back-end; that’s, unsurprisingly, one of the REST API project objectives after all.

The tests

Before I write any line of code I will write the first two failing functional tests:

<?php
namespace REST;

use FunctionalTester;

class NetworkOptionsCest
{
    /**
     * @test
     * it should mark bad request if trying to update lang_id for non existing blog_id
     */
    public function it_should_mark_bad_request_if_trying_to_update_lang_id_for_non_existing_blog_id(FunctionalTester $I)
    {
        $I->loginAsAdmin();
        $I->amOnAdminPage('/network/settings.php?page=tongues-network-options');
        $I->haveHttpHeader('X-WP-Nonce', $I->grabValueFrom('input[name="tongues-nonce"]'));
        $I->sendAjaxPostRequest('/wp-json/tongues/v1/network-settings', [
            'lang_ids' => [
                ['blog_id' => 21, 'lang_id' => 23]
            ]
        ]);

        $I->canSeeResponseCodeIs('400');
    }

    /**
     * @test
     * it should update an existing blog lang_id
     */
    public function it_should_update_an_existing_blog_lang_id(FunctionalTester $I)
    {
        $I->loginAsAdmin();
        $I->amOnAdminPage('/network/settings.php?page=tongues-network-options');
        $I->haveHttpHeader('X-WP-Nonce', $I->grabValueFrom('input[name="tongues-nonce"]'));
        $I->sendAjaxPostRequest('/wp-json/tongues/v1/network-settings', [
            'lang_ids' => [
                ['blog_id' => 2, 'lang_id' => 12]
            ]
        ]);

        $I->canSeeResponseCodeIs('200');
        $I->seeBlogInDatabase(['blog_id' => 2, 'lang_id' => 12]);
    }
}


The test methods names are ablative enough to understand the expectations and assertions I set at the end of both but the main benefit of writing these tests, or any test for the matter, before any real business code is in place is to focus on what that business code is meant accomplish in place of how it’s going to accomplish it.
These tests are telling me I have to register an endpoint at /wp-json/tongues/v1/network-settings and whatever is listening on that endpoint should parse a POST HTTP request that might contain a blog_id to lang_id relating payload.

The first business code

I’ve implemented the behaviour suggested with a web of classes suiting my coding style of preference, that can be seen on GitHub, but the core of the code that will make the tests pass lives in the /src/API/Handlers/NetworkSettings.php (see it on GitHub) class:

<?php

namespace Tongues\API\Handlers;


class NetworkSettings implements NetworkSettingsHandlerInterface
{

    /**
     * @var array
     */
    protected $blogIdsForLangIdsCache = [];

    public function handle(\WP_REST_Request $request)
    {
        if (!current_user_can('manage_network')) {
            return new \WP_REST_Response('Invalid auth', 403);
        }

        $lang_ids = $request->get_param('lang_ids');

        if (!is_array($lang_ids) || empty($lang_ids)) {
            return new \WP_REST_Response('lang_ids is missing from request or empty', 400);
        }

        array_filter($lang_ids, [$this, 'filterInvalidLangIdEntries']);

        if (empty($lang_ids)) {
            return new \WP_REST_Response('Invalid lang_ids entry', 400);
        }

        $lang_ids = $this->filterInexistentBlogsIdsEntries($lang_ids);

        if (empty($lang_ids)) {
            return new \WP_REST_Response('No blog_id specified exists', 400);
        }

        array_filter($lang_ids, [$this, 'filterUnchangedLangIdEntries']);

        if (empty($lang_ids)) {
            return new \WP_REST_Response('No lang_id changes made', 200);
        }

        return $this->updateBlogLangIds($lang_ids);
    }

    public function filterInexistentBlogsIdsEntries($langIdEntries)
    {
        $blogIds = array_map(function (array $langIdEntry) {
            return $langIdEntry['blog_id'];
        }, $langIdEntries);

        $langIdForBlog = $this->getLangIdForBlogIds($blogIds);

        $existingBlogIds = array_intersect(array_keys($langIdForBlog), $blogIds);

        if (empty($existingBlogIds)) {
            return [];
        }

        $filtered = [];
        foreach ($langIdEntries as $langIdEntry) {
            if (in_array($langIdEntry['blog_id'], $existingBlogIds)) {
                $filtered[] = $langIdEntry;
            }
        }

        return $filtered;
    }

    private function getLangIdForBlogIds($blogIds)
    {
        if (empty($this->blogIdsForLangIdsCache)) {
            /** @var \wpdb $wpdb */
            global $wpdb;

            $blogIdsIn = implode(',', array_map(function ($blogId) use ($wpdb) {
                return $wpdb->prepare('%d', $blogId);
            }, $blogIds));

            $blogIdsAndLangIds = $wpdb->get_results("SELECT blog_id, lang_id FROM {$wpdb->blogs} WHERE blog_id IN ({$blogIdsIn})");
            $this->blogIdsForLangIdsCache = array_combine(wp_list_pluck($blogIdsAndLangIds, 'blog_id'), wp_list_pluck($blogIdsAndLangIds, 'lang_id'));
        }

        return $this->blogIdsForLangIdsCache;
    }

    private function updateBlogLangIds(array $langIdEntries)
    {
        /** @var \wpdb $wpdb */
        global $wpdb;

        try {
            foreach ($langIdEntries as $langIdEntry) {
                $updated = $wpdb->update($wpdb->blogs, ['lang_id' => $langIdEntry['lang_id']], ['blog_id' => $langIdEntry['blog_id']], ['%d'], ['%d']);

                if (false === $updated) {
                    throw new \RuntimeException('There was a problem updating lang_id value to [' . $langIdEntry['lang_id'] . '] for blog_id [' . $langIdEntry['blog_id'] . ']');
                }
            }
        } catch (\RuntimeException $e) {
            return new \WP_REST_Response($e->getMessage(), 500);
        }

        return new \WP_REST_Response('lang_ids updated', 200);
    }

    protected function filterInvalidLangIdEntries($langIdEntry)
    {
        if (!is_array($langIdEntry) || empty($langIdEntry)) {
            return false;
        }

        if (!(isset($langIdEntry['blog_id'])
            && is_numeric($langIdEntry['blog_id'])
            && isset($langIdEntry['lang_id'])
            && is_numeric($langIdEntry['lang_id']))
        ) {
            return false;
        }

        return true;
    }

    protected function filterUnchangedLangIdEntries(array $langIdEntry)
    {
        return $langIdEntry['lang_id'] != $this->blogIdsForLangIdsCache[$langIdEntry['blog_id']];
    }
}

I’ve skipped the unit and integration tests for the time being to concentrate on the two functional tests but will get back to those next.
Worth noting is that, since I’m tapping into the REST API infrastructure support, the current_user_can('manage_network') checks I’m making in the code rely on its built-in nonce-based authentication mechanism.
In both the tests I will authenticate as the admin, which is the network super-admin user too, and navigate to the Tongues network settings page to get hold of a valid nonce for the wp_rest action.
In the tests the two sides of this nonce publication and usage are:

$I->loginAsAdmin();
$I->amOnAdminPage('/network/settings.php?page=tongues-network-options');
$I->haveHttpHeader('X-WP-Nonce', $I->grabValueFrom('input[name="tongues-nonce"]'));

and in the /src/UI/Admin/NetworkOptions class responsible for that options page rendering:

<?php

namespace Tongues\UI\Admin;


use Tongues\API\Endpoints\RoutesInformationInterface;

class NetworkOptions extends AbstractOptionsPage implements OptionsPageInterface, NetworkOptionsPageInterface
{
    /**
     * @var RoutesInformationInterface
     */
    private $routesInformation;

    public function __construct(RoutesInformationInterface $routesInformation)
    {
        $this->routesInformation = $routesInformation;
    }

    public function render()
    {
        wp_nonce_field($this->getNonceAction(), $this->getNonceField());
    }
}

Tongues first two functional tests passing

Next

I will add more functional tests, add the missing integration tests and unit tests and move on with the code.