Testing Email generation in CakePHP 3

Need to get the Emails generated while running functional tests on your CakePHP 3.2 app? The following recipe might be of interest.

First we need a TestTransport class:

Filename: src/Mailer/Transport/TestTransport.php

namespace App\Mailer\Transport;

use Cake\Core\Configure;
use Cake\Mailer\AbstractTransport;
use Cake\Mailer\Email;

/**
 * Test environment Email Transport
 *
 * @author     Ron Metten <ccct@code-kobold.de>
 * @copyright  Ron Metten <ccct@code-kobold.de>
 */
class TestTransport extends AbstractTransport
{
    /**
     * Send mail.
     *
     * @param \Cake\Mailer\Email $email Cake Email
     * @return array
     */
    public function send(Email $email)
    {
        $headers = $email->getHeaders(['from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'subject']);
        $headers = trim($this->_headersToString($headers));
        $message = trim(implode("\r\n", (array)$email->message()));
        $result = ['headers' => $headers, 'message' => $message];
        Configure::write('test_transport_email.' . $email->subject(), $result);
        return $result;
    }
}

Here we (ab)use the Configure class to store the Email contents.

Add the TestTransport to your config/app.php:

Filename: config/app.php

'EmailTransport' => [
    'default' => [
        'className' => ...,
    ],
    'test' => [
        'className' => 'Test',
    ],
],

Now configure the transport in your test bootstrap file (the one you defined in the phpunit.xml file you use for running your tests, probably tests/bootstrap.php):

Filename: tests/bootstrap.php

use Cake\Mailer\Email;

require dirname(__DIR__) . '/config/bootstrap.php';

/*
 * Change default Email transport to 'test
 */
Email::drop('default');
Email::config('default', [
    'transport' => 'test',
]);

Now generate a test class:

Filename: tests/TestCase/EmailTest.php

namespace App\Test\TestCase;

use Cake\Core\Configure;
use Cake\Mailer\Email;
use Cake\TestSuite\IntegrationTestCase;

class EmailTest extends IntegrationTestCase
{
    public function testMail()
    {
        $subject = 'Emails in functional tests';
        $message = 'A message to CakePHP 3';

        $email = new Email('default');
        $email->from(['me@example.com' => 'Codetalk'])
            ->to('you@example.com')
            ->subject($subject)
            ->send($message);

        $emailContent = Configure::read('test_transport_email.' . $subject);
        $this->assertStringStartsWith($message, $emailContent['message']);
    }
}

The run your test with some Cafe au Lait…

A CakePHP 3 PDF View

While evaluating PDF generator libraries to use with CakePHP 3.2, I came across this Pdf and graphic files generator library for PHP. It employs XML files with enhanced HTML tags for content and XML files with custom attributes for layout. While this approach and its syntax might occur cumbersome at the first (and probably second) glance, the results are quite accurate. The library can be installed with Composer.

Setting up a PDF view in Cakephp3 is quite simple; all it needs is

  • a Component to handle the Request
  • a View to enable the conversion
  • some Initialization in the Controller
  • The registration of the .pdf extension in the Router

In the View, we set a custom extension for the XML templates, to avoid having IDE like PHPStorm complain about language-related inspections:
$this->_ext = '.xctp';

The PdfComponent detects a PDF request and, if so, sets the PDF View to handle it.

Filename: src/Controller/Component/PdfComponent.php

<?php

namespace App\Controller\Component;

use Cake\Controller\Component;
use Cake\Controller\ComponentRegistry;
use Cake\Core\Configure;
use Cake\Controller\Controller;
use Cake\Event\Event;

/**
 * PDF Component to respond to PDF requests.
 *
 * Employs  App\View\PdfView to change output from HTML to PDF format.
 */
class PdfComponent extends Component {

    public $Controller;

    public $respondAsPdf = false;

    protected $_defaultConfig = [
        'viewClass' => 'Pdf',
        'autoDetect' => true
    ];

    /**
     * Constructor.
     *
     * @param ComponentRegistry $collection
     * @param array $config
     */
    public function __construct(ComponentRegistry $collection, $config = []) {
        $this->Controller = $collection->getController();
        $config += $this->_defaultConfig;
        parent::__construct($collection, $config);
    }

    /**
     * @inheritdoc
     */
    public function initialize(array $config = []) {
        if (!$this->_config['autoDetect']) {
            return;
        }
        $this->respondAsPdf = $this->Controller->request->is('pdf');
    }

    /**
     * Called before:
     * Controller::beforeRender()
     * the View class is loaded
     * Controller::render()
     *
     * @param Event $event
     * @return void
     */
    public function beforeRender(Event $event) {
        if (!$this->respondAsPdf) {
            return;
        }
        $this->_respondAsPdf();
    }

    /**
     * @return void
     */
    protected function _respondAsPdf() {
        $this->Controller
            ->viewBuilder()
            ->className($this->_config['viewClass']);
    }
}

Filename: src/View/PdfView.php

<?php

namespace App\View;

use Cake\Event\EventManager;
use Cake\Network\Request;
use Cake\Network\Response;
use Cake\View\View;
use PHPPdf\Core\FacadeBuilder;
use PHPPdf\DataSource\DataSource;

/**
 * View to handle PDF requests
 * using psliwa/php-pdf
 *
 * Covers incoming requests with '.pdf' extension.
 */
class PdfView extends View {

	/**
	 * Controller variables to provide as View class properties
	 *
	 * @var array
	 */
	protected $_passedVars = [
        'autoLayout', 'ext', 'helpers', 'layout',
        'layoutPath', 'name', 'passedArgs',
        'plugin', 'subDir', 'template',
        'templatePath', 'theme', 'view', 'viewVars',
    ];

	/**
	 * View templates subdirectory.
     * /pdf
	 *
	 * @var string
	 */
	public $subDir = null;

	/**
	 * Layout name for this View.
	 *
	 * @var string
	 */
	public $layout = false;

	/**
	 * Constructor
	 *
	 * @param \Cake\Network\Request|null $request Request instance.
	 * @param \Cake\Network\Response|null $response Response instance.
	 * @param \Cake\Event\EventManager|null $eventManager Event manager instance.
	 * @param array $viewOptions View options. cf. $_passedVars
	 */
	public function __construct(
		Request $request = null,
		Response $response = null,
		EventManager $eventManager = null,
		array $viewOptions = []
	) {
		parent::__construct($request, $response, $eventManager, $viewOptions);

		if ($this->subDir === null) {
			$this->subDir = 'pdf';
			$this->templatePath = str_replace(DS . 'pdf', '', $this->templatePath);
		}

		if (isset($response)) {
			$response->type('pdf');
		}

        /**
         * Use a custom extension here, to prevent IDE like PHPStorm
         * to complain about inspections
         */
        $this->_ext = '.xctp';
	}

	/**
	 * Renders a PDF view.
     *
     * Employs Cake\View\View::render() to parse templates,
     * builds the PDF from that result and returns this PDF
	 * with the response object.
	 **
	 * @param string $view Rendering view.
	 * @param string $layout rendering layout.
	 * @return string Rendered view.
	 */
	public function render($view = null, $layout = null) {

        $pathinfo = pathinfo($this->_getViewFileName());
        $stylesheetName = $pathinfo['dirname'] . DS . $pathinfo['filename'] . '.style.xml';

        $content = parent::render($view, $layout);
        $facade = FacadeBuilder::create()->build();

        $stylesheetXml = file_get_contents($stylesheetName);
        $stylesheet = DataSource::fromString($stylesheetXml);
        $content = $facade->render($content, $stylesheet);

        return $content;
	}

}

In the Controllers initialize() method, we load the PDFComponent and add a detector for the PDF view:

public function initialize()
{
  parent::initialize();
  $this->loadComponent('RequestHandler');
  ...
  $this->loadComponent('Pdf', ['viewClass' => 'Pdf', 'autoDetect' => true]);
  Request::addDetector('pdf', ['accept' => ['application/pdf'], 'param' => '_ext', 'value' => 'pdf']);
}

This will add that detector to Cake\Network\Request::$_detectors. The RequestHandler must be loaded too.

Finally, add the .pdf extension to your router:

Filename:config/routes.php
...
Router::extensions(['pdf']);

That’s basically it! Now you can use the same conventions as with regular template files. A PDF request to App\Controller\FooController::baz() with the URL /foo/baz.pdf will expect its template under Template/Foo/pdf/baz.xctp with its stylesheet under Template/Foo/pdf/baz.style.xctp.

  • Controller Action: App\Controller\FooController::baz()
  • URL: /foo/baz.pdf
  • Template: Template/Foo/pdf/baz.xctp
  • Stylesheet: Template/Foo/pdf/baz.style.xctp

In your Action, set variables and use them in your .xctp template as in any other template.