The path to an automagical multisite switch 03

Where I walk back from a wrong path and move on another.

Two small files

The missing piece in the ongoing struggle for an automagical switch of a local test installation of WordPress from single to multisite and back is what happens before WordPress code handles a request. WordPress will rely on the wp-config.php and .htacess to have requests hit the right URL; on single site installations the latter might not be there if pretty permalinks are not active but the .htaccess file is required for multisite installations.
I'd like to provide the tester with a painless way to have a basic .htaccess and wp-config.php files put "in front" of the site when and if trying to spin up a multisite installation starting from a single one.

Wrong approach

Last work involved a file to be loaded by the local WordPress installation wp-config.php file.
In the long run this will require an hefty maintenance effort on the tester user side that's bothering me so I've came up with an alternative approach.
In essence I need to do something before the WordPress index file is hit by the request; relying on some code to run when the wp-config.php file is included comes late into the request handling party.
Furthermore at least one request has to hit the site before the tests run and that might create a bias in testing that's unwanted and difficult to debug.
While I tried to shy away from it I will add an optional configuration parameter to the WPDb module to let the user specify the path to the local WordPress installation; not too dissimilar from what WPLoader configuration requires.

Config before the config

I will deal with files from this moment and the constant definition I had solved with the wp-config.php injection has to move to an alternative wp-config.php file that will precede the WordPress one.
The steps to cover for this to happen are the ones below:

  1. rename the wp-config.php file to original-wp-config.php
  2. create a new wp-config.php file that will only define the constants needed for multisite
  3. let the tests run
  4. when the tests did run, at _after method time, restore the original wp-config.php file

On to some tests I will scaffold a new unit test

codecept generate:test unit "\tad\WPBrowser\Utils\WPConfigReplacer" 

The unit suite is not configured to use the Codeception Filesystem module but the test case will extend the \Codeception\TestCase\Test class that will extend, in turn, PhpUnit base test case: I will be able to access all of the file system handling methods defined in it.
Since I will want to make assertions and set up fixtures dealing with the filesystem I will pull in the vfsStream package using Composer:

composer require --dev mikey179/vfsStream

Once the package is in it's time to write the first test.
I will not be testing the WPDb module directly but will develop an helper class the module will use to work the magic, this accessory class will only require the path to the WordPress installation to work.

Testing for failure

I will write the first tests to make sure the class will throw when not in running condition; since the class depends on a file path only this is not such a big effort:

namespace tad\WPBrowser\Utils;


use Codeception\Exception\ModuleConfigException;
use org\bovigo\vfs\vfsStream;

class WPConfigReplacerTest extends \Codeception\TestCase\Test {

    /**
     * @var \UnitTester
     */
    protected $tester;

    protected function _before() {
        $this->fsRoot = vfsStream::setup( 'root', null, [
            'missing-wp-config' => [ ],
            'wordpress'         => [ 'wp-config.php' => 'some config contents' ]
        ] );
    }

    protected function _after() {
    }

    /**
     * @test
     * it should throw if destination path is not a string
     */
    public function it_should_throw_if_destination_path_is_not_a_string() {
        $path = 23;
        $this->setExpectedException( '\Codeception\Exception\ModuleConfigException' );

        $sut = new WPConfigReplacer( $path );
    }

    /**
     * @test
     * it should throw if destination path is not a folder
     */
    public function it_should_throw_if_destination_path_is_not_a_folder() {
        $path = 23;
        $this->setExpectedException( '\Codeception\Exception\ModuleConfigException' );

        $sut = new WPConfigReplacer( vfsStream::url( 'root' ) . '/wordpress/wp-config.php' );
    }

    /**
     * @test
     * it should throw if destination path is not readable
     */
    public function it_should_throw_if_destination_path_is_not_readable() {
        $path = 23;
        $this->setExpectedException( '\Codeception\Exception\ModuleConfigException' );
        vfsStream::setup( 'writeable', 0222, [ 'wordpress' => [ 'wp-config.php' ] ] );

        $sut = new WPConfigReplacer( vfsStream::url( 'writeable' ) . '/wordpress/wp-config.php' );
    }

    /**
     * @test
     * it should throw if destination file is not writeable
     */
    public function it_should_throw_if_destination_file_is_not_writeable() {
        $path = 23;
        $this->setExpectedException( '\Codeception\Exception\ModuleConfigException' );
        vfsStream::setup( 'readable', 0444, [ 'wordpress' => [ 'wp-config.php' ] ] );

        $sut = new WPConfigReplacer( vfsStream::url( 'readable' ) . '/wordpress/wp-config.php' );
    }

    /**
     * @test
     * it should throw if the root folder does not containa a wp-config.php file
     */
    public function it_should_throw_if_the_root_folder_does_not_containa_a_wp_config_php_file() {
        $path = 23;
        $this->setExpectedException( '\Codeception\Exception\ModuleConfigException' );

        $sut = new WPConfigReplacer( vfsStream::url( 'root' ) . '/missing-wp-config' );
    }

    /**
     * @test
     * it should not throw if root folder is write and read able
     */
    public function it_should_not_throw_if_root_folder_is_write_and_read_able() {
        $path = 23;

        $sut = new WPConfigReplacer( vfsStream::url( 'root' ) . '/wordpress' );
    }
}

The vfsStream library abstracts the painful creation of complex filesystem fixtures in a few methods I'm using above; being able to set permissions on the file tree is a bonus that makes dealing with what is usually a painful, lengthy and error prone process a one-liner.
The final test method asserts the "all greeen state" where the class is ready to run and is not complaining.
This is the produced code:

namespace tad\WPBrowser\Utils;


use Codeception\Exception\ModuleConfigException;

class WPConfigReplacer {

    /**
     * @var string The absolute path to the WordPress installation folder.
     */
    protected $path;

    /**
     * WPConfigReplacer constructor.
     */
    public function __construct( $path ) {
        if ( !is_string( $path ) ) {
            throw new ModuleConfigException( __CLASS__, 'WordPress installation root path must be a string' );
        }
        if ( !is_dir( $path ) ) {
            throw new ModuleConfigException( __CLASS__, 'WordPress installation root path must point to a directory.' );
        }
        $path = rtrim( $path, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
        if ( !file_exists( $path . 'wp-config.php' ) ) {
            throw new ModuleConfigException( __CLASS__, 'WordPress installation root path must contain a wp-config.php file.' );
        }

        $this->path = $path;
    }
}

Renaming the original config file

I will add some tests to make sure the class properly handles the original wp-config.php file and carefully restores it when required to do so.
Again starting from some tests

/**
 * @test
 * it should move the original wp-config.php file to original-wp-config.php
 */
public function it_should_move_the_original_wp_config_php_file_to_original_wp_config_php() {
    $original         = vfsStream::url( 'root' ) . '/wordpress/wp-config.php';
    $moved            = vfsStream::url( 'root' ) . '/wordpress/original-wp-config.php';
    $wpconfigContents = $this->contentsProvider();

    $sut = new WPConfigReplacer( vfsStream::url( 'root' ) . '/wordpress', $wpconfigContents->reveal() );
    $sut->replaceOriginal();

    $this->assertFileExists( $moved );
    $this->assertEquals( 'original', file_get_contents( $moved ) );
}

/**
 * @test
 * it should create an alternative wp-config.php file
 */
public function it_should_create_an_alternative_wp_config_php_file() {
    $wpconfigContents = $this->contentsProvider();
    $sut              = new WPConfigReplacer( vfsStream::url( 'root' ) . '/wordpress', $wpconfigContents->reveal() );
    $sut->replaceOriginal();

    $file = vfsStream::url( 'root' ) . '/wordpress/wp-config.php';
    $this->assertFileExists( $file );
    $this->assertEquals( 'modified', file_get_contents( $file ) );
}

/**
 * @test
 * it should restore the original wp-config.php file
 */
public function it_should_restore_the_original_wp_config_php_file() {
    $wpconfigContents = $this->contentsProvider();
    $sut              = new WPConfigReplacer( vfsStream::url( 'root' ) . '/wordpress', $wpconfigContents->reveal() );

    $sut->replaceOriginal();
    $sut->restoreOriginal();

    $file = vfsStream::url( 'root' ) . '/wordpress/wp-config.php';
    $this->assertFileExists( $file );
    $this->assertEquals( 'original', file_get_contents( $file ) );
}

/**
 * @test
 * it should unlink the alternative wp-config.php file
 */
public function it_should_unlink_the_alternative_wp_config_php_file() {
    $wpconfigContents = $this->contentsProvider();
    $sut              = new WPConfigReplacer( vfsStream::url( 'root' ) . '/wordpress', $wpconfigContents->reveal() );

    $sut->replaceOriginal();
    $sut->restoreOriginal();

    $file = vfsStream::url( 'root' ) . '/wordpress/original-wp-config.php';
    $this->assertFileNotExists( $file );
}

I found out I needed another dependency along the way: a template class to provide for the modified wp-config.php file contents (see $wpconfigContents); while I could have implemeneted the code for it in this very class I feel this is not a shortcut and, beside violating the single responsibility principle, it's also not going to pay in the long run.
I've not even stubbed the class but for its interface, down to one method at the moment, and was able to complete the code for the WPConfigReplacer class:

namespace tad\WPBrowser\Utils;


use Codeception\Exception\ModuleConfigException;
use tad\WPBrowser\Generators\WPConfig\WPConfigGeneratorInterface;

class WPConfigReplacer {

    /**
     * @var string
     */
    protected $original;

    /**
     * @var string
     */
    protected $moved;

    /**
     * @var string The absolute path to the WordPress installation folder.
     */
    protected $path;

    /**
     * @var WPConfigGeneratorInterface
     */
    protected $contentsProvider;

    /**
     * WPConfigReplacer constructor.
     */
    public function __construct( $path, WPConfigGeneratorInterface $contentsProvider ) {
        if ( !is_string( $path ) ) {
            throw new ModuleConfigException( __CLASS__, 'WordPress installation root path must be a string' );
        }
        if ( !is_dir( $path ) ) {
            throw new ModuleConfigException( __CLASS__, 'WordPress installation root path must point to a directory.' );
        }
        $path = rtrim( $path, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
        if ( !file_exists( $path . 'wp-config.php' ) ) {
            throw new ModuleConfigException( __CLASS__, 'WordPress installation root path must contain a wp-config.php file.' );
        }

        $this->path             = $path;
        $this->contentsProvider = $contentsProvider;
        $this->moved            = $this->path . 'original-wp-config.php';
        $this->original         = $this->path . 'wp-config.php';
    }

    public function replaceOriginal() {
        rename( $this->original, $this->moved );
        file_put_contents( $this->original, $this->contentsProvider->getContents() );
    }

    public function restoreOriginal() {
        unlink( $this->original );
        rename( $this->moved, $this->original );
    }
}

As usual in TDD the result of testing is little produced code.

Next

I will develop a class similar to this one to handle the .htaccess file, wrap up accessory classes and then move on to more acceptance testing to make sure the single to multisite swap works.