Browse Source

Merge pull request #95 from dereuromark/cake3-tree

Upgrade RssView
Mark S. 11 years ago
parent
commit
ea0376510b

+ 1 - 0
phpunit.xml.dist

@@ -1,6 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <phpunit
 	colors="true"
+	backupStaticAttributes="false"
 	processIsolation="false"
 	stopOnFailure="false"
 	syntaxCheck="false"

+ 1 - 1
src/Console/Command/WhitespaceShell.php

@@ -2,7 +2,7 @@
 namespace Tools\Console\Command;
 
 use Cake\Console\Shell;
-use Cake\Utility\Folder;
+use Cake\Filesystem\Folder;
 use Cake\Utility\Inflector;
 use Cake\Core\Plugin;
 

+ 389 - 0
src/View/RssView.php

@@ -0,0 +1,389 @@
+<?php
+/**
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @author Mark Scherer
+ * @license http://www.opensource.org/licenses/mit-license.php MIT License
+ * @link http://www.dereuromark.de/2013/10/03/rss-feeds-in-cakephp
+ */
+namespace Tools\View;
+
+use Cake\Core\Configure;
+use Cake\Routing\Router;
+use Cake\Network\Request;
+use Cake\Network\Response;
+use Cake\View\View;
+use Cake\Utility\Xml;
+use Cake\I18n\Time;
+use App\Router\Routing;
+use Cake\Utility\Hash;
+
+/**
+ * A view class that is used for creating RSS feeds.
+ *
+ * By setting the '_serialize' key in your controller, you can specify a view variable
+ * that should be serialized to XML and used as the response for the request.
+ * This allows you to omit views + layouts, if your just need to emit a single view
+ * variable as the XML response.
+ *
+ * In your controller, you could do the following:
+ *
+ * `$this->set(array('posts' => $posts, '_serialize' => 'posts'));`
+ *
+ * When the view is rendered, the `$posts` view variable will be serialized
+ * into the RSS XML.
+ *
+ * **Note** The view variable you specify must be compatible with Xml::fromArray().
+ *
+ * If you don't use the `_serialize` key, you will need a view. You can use extended
+ * views to provide layout like functionality. This is currently not yet tested/supported.
+ */
+class RssView extends View {
+
+	/**
+	 * Default spec version of generated RSS.
+	 *
+	 * @var string
+	 */
+	public $version = '2.0';
+
+	/**
+	 * The subdirectory. RSS views are always in rss. Currently not in use.
+	 *
+	 * @var string
+	 */
+	public $subDir = 'rss';
+
+	/**
+	 * Holds usable namespaces.
+	 *
+	 * @var array
+	 * @link http://validator.w3.org/feed/docs/howto/declare_namespaces.html
+	 */
+	protected $_namespaces = array(
+		'atom' => 'http://www.w3.org/2005/Atom',
+		'content' => 'http://purl.org/rss/1.0/modules/content/',
+		'dc' => 'http://purl.org/dc/elements/1.1/',
+		'sy' => 'http://purl.org/rss/1.0/modules/syndication/'
+	);
+
+	/**
+	 * Holds the namespace keys in use.
+	 *
+	 * @var array
+	 */
+	protected $_usedNamespaces = array();
+
+	/**
+	 * Holds CDATA placeholders.
+	 *
+	 * @var array
+	 */
+	protected $_cdata = array();
+
+	/**
+	 * Constructor
+	 *
+	 * @param Controller $controller
+	 */
+	public function __construct(Request $request = null, Response $response = null,
+		EventManager $eventManager = null, array $viewOptions = []) {
+		parent::__construct($request, $response, $eventManager, $viewOptions);
+
+		if ($response && $response instanceof Response) {
+			$response->type('rss');
+		}
+	}
+
+	/**
+	 * If you are using namespaces that are not yet known to the class, you need to globablly
+	 * add them with this method. Namespaces will only be added for actually used prefixes.
+	 *
+	 * @param string $prefix
+	 * @param string $url
+	 * @return void
+	 */
+	public function setNamespace($prefix, $url) {
+		$this->_namespaces[$prefix] = $url;
+	}
+
+	/**
+	 * Prepares the channel and sets default values.
+	 *
+	 * @param array $channel
+	 * @return array Channel
+	 */
+	public function channel($channel) {
+		if (!isset($channel['link'])) {
+			$channel['link'] = '/';
+		}
+		if (!isset($channel['title'])) {
+			$channel['title'] = '';
+		}
+		if (!isset($channel['description'])) {
+			$channel['description'] = '';
+		}
+
+		$channel = $this->_prepareOutput($channel);
+		return $channel;
+	}
+
+	/**
+	 * Converts a time in any format to an RSS time
+	 *
+	 * @param int|string|DateTime $time
+	 * @return string An RSS-formatted timestamp
+	 * @see Time::toRSSString
+	 */
+	public function time($time) {
+		$time = new Time($time);
+		return $time->toRSSString();
+	}
+
+	/**
+	 * Skip loading helpers if this is a _serialize based view.
+	 *
+	 * @return void
+	 */
+	public function loadHelpers() {
+		if (isset($this->viewVars['_serialize'])) {
+			return;
+		}
+		parent::loadHelpers();
+	}
+
+	/**
+	 * Render a RSS view.
+	 *
+	 * Uses the special '_serialize' parameter to convert a set of
+	 * view variables into a XML response. Makes generating simple
+	 * XML responses very easy. You can omit the '_serialize' parameter,
+	 * and use a normal view + layout as well.
+	 *
+	 * @param string $view The view being rendered.
+	 * @param string $layout The layout being rendered.
+	 * @return string The rendered view.
+	 */
+	public function render($view = null, $layout = null) {
+		if (isset($this->viewVars['_serialize'])) {
+			return $this->_serialize($this->viewVars['_serialize']);
+		}
+		if ($view !== false && $this->_getViewFileName($view)) {
+			return parent::render($view, false);
+		}
+	}
+
+	/**
+	 * Serialize view vars.
+	 *
+	 * @param string|array $serialize The viewVars that need to be serialized.
+	 * @return string The serialized data
+	 * @throws RuntimeException When the prefix is not specified
+	 */
+	protected function _serialize($serialize) {
+		$rootNode = isset($this->viewVars['_rootNode']) ? $this->viewVars['_rootNode'] : 'channel';
+
+		if (is_array($serialize)) {
+			$data = array($rootNode => array());
+			foreach ($serialize as $alias => $key) {
+				if (is_numeric($alias)) {
+					$alias = $key;
+				}
+				$data[$rootNode][$alias] = $this->viewVars[$key];
+			}
+		} else {
+			$data = isset($this->viewVars[$serialize]) ? $this->viewVars[$serialize] : null;
+			if (is_array($data) && Hash::numeric(array_keys($data))) {
+				$data = array($rootNode => array($serialize => $data));
+			}
+		}
+
+		$defaults = array('document' => array(), 'channel' => array(), 'items' => array());
+		$data += $defaults;
+		if (!empty($data['document']['namespace'])) {
+			foreach ($data['document']['namespace'] as $prefix => $url) {
+				$this->setNamespace($prefix, $url);
+			}
+		}
+
+		$channel = $this->channel($data['channel']);
+		if (!empty($channel['image']) && empty($channel['image']['title'])) {
+			$channel['image']['title'] = $channel['title'];
+		}
+
+		foreach ($data['items'] as $item) {
+			$channel['item'][] = $this->_prepareOutput($item);
+		}
+
+		$array = array(
+			'rss' => array(
+				'@version' => $this->version,
+				'channel' => $channel,
+			)
+		);
+		$namespaces = array();
+		foreach ($this->_usedNamespaces as $usedNamespacePrefix) {
+			if (!isset($this->_namespaces[$usedNamespacePrefix])) {
+				throw new \RuntimeException(__('The prefix %s is not specified.', $usedNamespacePrefix));
+			}
+			$namespaces['xmlns:' . $usedNamespacePrefix] = $this->_namespaces[$usedNamespacePrefix];
+		}
+		$array['rss'] += $namespaces;
+
+		$options = array();
+		if (Configure::read('debug')) {
+			$options['pretty'] = true;
+		}
+
+		$output = Xml::fromArray($array, $options)->asXML();
+		$output = $this->_replaceCdata($output);
+
+		return $output;
+	}
+
+	/**
+	 * RssView::_prepareOutput()
+	 *
+	 * @param array $item
+	 * @return array
+	 */
+	protected function _prepareOutput($item) {
+		foreach ($item as $key => $val) {
+			$prefix = null;
+			// The cast prevents a PHP bug for switch case and false positives with integers
+			$bareKey = (string)$key;
+
+			// Detect namespaces
+			if (strpos($key, ':') !== false) {
+				list($prefix, $bareKey) = explode(':', $key, 2);
+				if (strpos($prefix, '@') !== false) {
+					$prefix = substr($prefix, 1);
+				}
+				if (!in_array($prefix, $this->_usedNamespaces)) {
+					$this->_usedNamespaces[] = $prefix;
+				}
+			}
+
+			$attrib = null;
+			switch ($bareKey) {
+				case 'encoded':
+					$val = $this->_newCdata($val);
+					break;
+
+				case 'pubDate':
+					$val = $this->time($val);
+					break;
+
+				case 'category':
+					if (is_array($val) && isset($val['domain'])) {
+						$attrib['@domain'] = $val['domain'];
+						$attrib['@'] = isset($val['content']) ? $val['content'] : $attrib['@domain'];
+						$val = $attrib;
+					} elseif (is_array($val) && !empty($val[0])) {
+						$categories = array();
+						foreach ($val as $category) {
+							$attrib = array();
+							if (is_array($category) && isset($category['domain'])) {
+								$attrib['@domain'] = $category['domain'];
+								$attrib['@'] = isset($val['content']) ? $val['content'] : $attrib['@domain'];
+								$category = $attrib;
+							}
+							$categories[] = $category;
+						}
+						$val = $categories;
+					}
+					break;
+
+				case 'link':
+				case 'url':
+				case 'guid':
+				case 'comments':
+					if (is_array($val) && isset($val['@href'])) {
+						$attrib = $val;
+						$attrib['@href'] = Router::url($val['@href'], true);
+						if ($prefix === 'atom') {
+							$attrib['@rel'] = 'self';
+							$attrib['@type'] = 'application/rss+xml';
+						}
+						$val = $attrib;
+
+					} elseif (is_array($val) && isset($val['url'])) {
+						$val['url'] = Router::url($val['url'], true);
+						if ($bareKey === 'guid') {
+							$val['@'] = $val['url'];
+							unset($val['url']);
+						}
+					} else {
+						$val = Router::url($val, true);
+					}
+					break;
+
+				case 'source':
+					if (is_array($val) && isset($val['url'])) {
+						$attrib['@url'] = Router::url($val['url'], true);
+						$attrib['@'] = isset($val['content']) ? $val['content'] : $attrib['@url'];
+					} elseif (!is_array($val)) {
+						$attrib['@url'] = Router::url($val, true);
+						$attrib['@'] = $attrib['@url'];
+					}
+					$val = $attrib;
+					break;
+
+				case 'enclosure':
+					if (isset($val['url']) && is_string($val['url']) && is_file(WWW_ROOT . $val['url']) && file_exists(WWW_ROOT . $val['url'])) {
+						if (!isset($val['length']) && strpos($val['url'], '://') === false) {
+							$val['length'] = sprintf("%u", filesize(WWW_ROOT . $val['url']));
+						}
+						if (!isset($val['type']) && function_exists('mime_content_type')) {
+							$val['type'] = mime_content_type(WWW_ROOT . $val['url']);
+						}
+					}
+					$attrib['@url'] = Router::url($val['url'], true);
+					$attrib['@length'] = $val['length'];
+					$attrib['@type'] = $val['type'];
+					$val = $attrib;
+					break;
+
+				default:
+					//nothing
+			}
+
+			if (is_array($val)) {
+				$val = $this->_prepareOutput($val);
+			}
+
+			$item[$key] = $val;
+		}
+
+		return $item;
+	}
+
+	/**
+	 * RssView::_newCdata()
+	 *
+	 * @param string $content
+	 * @return string
+	 */
+	protected function _newCdata($content) {
+		$i = count($this->_cdata);
+		$this->_cdata[$i] = $content;
+		return '###CDATA-' . $i . '###';
+	}
+
+	/**
+	 * RssView::_replaceCdata()
+	 *
+	 * @param string $content
+	 * @return string
+	 */
+	protected function _replaceCdata($content) {
+		foreach ($this->_cdata as $n => $data) {
+			$data = '<![CDATA[' . $data . ']]>';
+			$content = str_replace('###CDATA-' . $n . '###', $data, $content);
+		}
+		return $content;
+	}
+
+}

+ 591 - 0
tests/TestCase/View/RssViewTest.php

@@ -0,0 +1,591 @@
+<?php
+/**
+ * PHP 5
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice
+ *
+ * @author        Mark Scherer
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Tools\Test\TestCase\View;
+
+use Cake\Routing\Router;
+use Cake\Controller\Controller;
+use Cake\Network\Request;
+use Cake\Network\Response;
+use Cake\TestSuite\TestCase;
+use Tools\View\RssView;
+
+/**
+ * RssViewTest
+ *
+ */
+class RssViewTest extends TestCase {
+
+	public $Rss;
+
+	public $baseUrl;
+
+	/**
+	 * RssViewTest::setUp()
+	 *
+	 * @return void
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		$this->Rss = new RssView();
+
+		$this->baseUrl = trim(Router::url('/', true), '/');
+	}
+
+	/**
+	 * TestTime method
+	 *
+	 * @return void
+	 */
+	public function testTime() {
+		$now = time();
+		$time = $this->Rss->time($now);
+		$this->assertEquals(date('r', $now), $time);
+	}
+
+	/**
+	 * RssViewTest::testSerialize()
+	 *
+	 * @return void
+	 */
+	public function testSerialize() {
+		$Request = new Request();
+		$Response = new Response();
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'link' => 'http://channel.example.org',
+				'description' => 'Channel description'
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => 'http://example.org/one',
+					'author' => 'one@example.org', 'description' => 'Content one',
+					'source' => array('url' => 'http://foo.bar')),
+				array('title' => 'Title Two', 'link' => 'http://example.org/two',
+					'author' => 'two@example.org', 'description' => 'Content two',
+					'source' => array('url' => 'http://foo.bar', 'content' => 'Foo bar')),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <link>http://channel.example.org</link>
+    <description>Channel description</description>
+    <item>
+      <title>Title One</title>
+      <link>http://example.org/one</link>
+      <author>one@example.org</author>
+      <description>Content one</description>
+      <source url="http://foo.bar">http://foo.bar</source>
+    </item>
+    <item>
+      <title>Title Two</title>
+      <link>http://example.org/two</link>
+      <author>two@example.org</author>
+      <description>Content two</description>
+      <source url="http://foo.bar">Foo bar</source>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		$this->assertSame('application/rss+xml', $Response->type());
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerialize()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithPrefixes() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$time = time();
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'link' => 'http://channel.example.org',
+				'description' => 'Channel description',
+				'sy:updatePeriod' => 'hourly',
+				'sy:updateFrequency' => 1
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => 'http://example.org/one',
+					'dc:creator' => 'Author One', 'pubDate' => $time),
+				array('title' => 'Title Two', 'link' => 'http://example.org/two',
+					'dc:creator' => 'Author Two', 'pubDate' => $time,
+					'source' => 'http://foo.bar'),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$time = date('r', $time);
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <link>http://channel.example.org</link>
+    <description>Channel description</description>
+    <sy:updatePeriod>hourly</sy:updatePeriod>
+    <sy:updateFrequency>1</sy:updateFrequency>
+    <item>
+      <title>Title One</title>
+      <link>http://example.org/one</link>
+      <dc:creator>Author One</dc:creator>
+      <pubDate>$time</pubDate>
+    </item>
+    <item>
+      <title>Title Two</title>
+      <link>http://example.org/two</link>
+      <dc:creator>Author Two</dc:creator>
+      <pubDate>$time</pubDate>
+      <source url="http://foo.bar">http://foo.bar</source>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		$this->assertSame('application/rss+xml', $Response->type());
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithUnconfiguredPrefix()
+	 *
+	 * @expectedException RuntimeException
+	 * @return void
+	 */
+	public function testSerializeWithUnconfiguredPrefix() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'channel' => array(
+				'foo:bar' => 'something',
+			),
+			'items' => array(
+				array('title' => 'Title Two'),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithArrayLinks()
+	 *
+	 * `'atom:link' => array('@href' => array(...)` becomes
+	 * '@rel' => 'self', '@type' => 'application/rss+xml' automatically set for atom:link
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithArrayLinks() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'link' => 'http://channel.example.org',
+				'atom:link' => array('@href' => array('controller' => 'foo', 'action' => 'bar')),
+				'description' => 'Channel description',
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content one'),
+				array('title' => 'Title Two', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content two'),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <link>http://channel.example.org</link>
+    <atom:link href="$this->baseUrl/foo/bar" rel="self" type="application/rss+xml"/>
+    <description>Channel description</description>
+    <item>
+      <title>Title One</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content one</description>
+    </item>
+    <item>
+      <title>Title Two</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content two</description>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		//debug($result);
+		$this->assertSame('application/rss+xml', $Response->type());
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithContent()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithContent() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'link' => 'http://channel.example.org',
+				'guid' => array('url' => 'http://channel.example.org', '@isPermaLink' => 'true'),
+				'atom:link' => array('@href' => array('controller' => 'foo', 'action' => 'bar')),
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content one',
+					'content:encoded' => 'HTML <img src="http://domain.com/some/link/to/image.jpg"/> <b>content</b> one'),
+				array('title' => 'Title Two', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content two',
+					'content:encoded' => 'HTML <img src="http://domain.com/some/link/to/image.jpg"/> <b>content</b> two'),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <link>http://channel.example.org</link>
+    <guid isPermaLink="true">http://channel.example.org</guid>
+    <atom:link href="$this->baseUrl/foo/bar" rel="self" type="application/rss+xml"/>
+    <description/>
+    <item>
+      <title>Title One</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content one</description>
+      <content:encoded><![CDATA[HTML <img src="http://domain.com/some/link/to/image.jpg"/> <b>content</b> one]]></content:encoded>
+    </item>
+    <item>
+      <title>Title Two</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content two</description>
+      <content:encoded><![CDATA[HTML <img src="http://domain.com/some/link/to/image.jpg"/> <b>content</b> two]]></content:encoded>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		//debug($output);
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithCustomNamespace()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithCustomNamespace() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'document' => array(
+				'namespace' => array(
+					'admin' => 'http://webns.net/mvcb/',
+					'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+				)
+			),
+			'channel' => array(
+				'title' => 'Channel title',
+				'admin:errorReportsTo' => array('@rdf:resource' => 'mailto:me@example.com')
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => array('controller' => 'foo', 'action' => 'bar')),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:admin="http://webns.net/mvcb/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <admin:errorReportsTo rdf:resource="mailto:me@example.com"/>
+    <link>$this->baseUrl/</link>
+    <description/>
+    <item>
+      <title>Title One</title>
+      <link>$this->baseUrl/foo/bar</link>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		//debug($result);
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithImage()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithImage() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$url = array('controller' => 'topics', 'action' => 'feed', '_ext' => 'rss');
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'guid' => array('url' => $url, '@isPermaLink' => 'true'),
+				'image' => array(
+					'url' => '/img/logo_rss.png',
+					'link' => '/'
+				)
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => array('controller' => 'foo', 'action' => 'bar')),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <guid isPermaLink="true">$this->baseUrl/topics/feed.rss</guid>
+    <image>
+      <url>$this->baseUrl/img/logo_rss.png</url>
+      <link>$this->baseUrl/</link>
+      <title>Channel title</title>
+    </image>
+    <link>$this->baseUrl/</link>
+    <description/>
+    <item>
+      <title>Title One</title>
+      <link>$this->baseUrl/foo/bar</link>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithCategories()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithCategories() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'link' => 'http://channel.example.org',
+				'category' => 'IT/Internet/Web development & more',
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content one',
+					'category' => 'Internet'),
+				array('title' => 'Title Two', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content two',
+					'category' => array('News', 'Tutorial'),
+					'comments' => array('controller' => 'foo', 'action' => 'bar', '_ext' => 'rss')),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <link>http://channel.example.org</link>
+    <category>IT/Internet/Web development &amp; more</category>
+    <description/>
+    <item>
+      <title>Title One</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content one</description>
+      <category>Internet</category>
+    </item>
+    <item>
+      <title>Title Two</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content two</description>
+      <category>News</category>
+      <category>Tutorial</category>
+      <comments>$this->baseUrl/foo/bar.rss</comments>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithEnclosure()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithEnclosure() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'link' => 'http://channel.example.org',
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content one',
+					'enclosure' => array('url' => 'http://www.example.com/media/3d.wmv', 'length' => 78645, 'type' => 'video/wmv')),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <link>http://channel.example.org</link>
+    <description/>
+    <item>
+      <title>Title One</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content one</description>
+      <enclosure url="http://www.example.com/media/3d.wmv" length="78645" type="video/wmv"/>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithCustomTags()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithCustomTags() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title',
+				'link' => 'http://channel.example.org',
+			),
+			'items' => array(
+				array('title' => 'Title One', 'link' => array('controller' => 'foo', 'action' => 'bar'), 'description' => 'Content one',
+					'foo' => array('@url' => 'http://www.example.com/media/3d.wmv', '@length' => 78645, '@type' => 'video/wmv')),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Channel title</title>
+    <link>http://channel.example.org</link>
+    <description/>
+    <item>
+      <title>Title One</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>Content one</description>
+      <foo url="http://www.example.com/media/3d.wmv" length="78645" type="video/wmv"/>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		$this->assertTextEquals($expected, $result);
+	}
+
+	/**
+	 * RssViewTest::testSerializeWithSpecialChars()
+	 *
+	 * @return void
+	 */
+	public function testSerializeWithSpecialChars() {
+		$Request = new Request();
+		$Response = new Response();
+
+		$data = array(
+			'channel' => array(
+				'title' => 'Channel title with äöü umlauts and <!> special chars',
+				'link' => 'http://channel.example.org',
+			),
+			'items' => array(
+				array(
+					'title' => 'A <unsafe title',
+					'link' => array('controller' => 'foo', 'action' => 'bar'),
+					'description' => 'My content "&" and <other> stuff here should also be escaped safely'),
+			)
+		);
+		$viewVars = array('channel' => $data, '_serialize' => 'channel');
+		$View = new RssView($Request, $Response, null, ['viewVars' => $viewVars]);
+		$result = $View->render(false);
+
+		$expected = <<<RSS
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Channel title with äöü umlauts and &lt;!&gt; special chars</title>
+    <link>http://channel.example.org</link>
+    <description/>
+    <item>
+      <title>A &lt;unsafe title</title>
+      <link>$this->baseUrl/foo/bar</link>
+      <description>My content "&amp;" and &lt;other&gt; stuff here should also be escaped safely</description>
+    </item>
+  </channel>
+</rss>
+
+RSS;
+		$this->assertTextEquals($expected, $result);
+	}
+
+}

+ 10 - 2
tests/bootstrap.php

@@ -10,12 +10,20 @@ define('CAKE_CORE_INCLUDE_PATH', ROOT . '/vendor/cakephp/cakephp');
 define('CORE_PATH', CAKE_CORE_INCLUDE_PATH . DS);
 define('CAKE', CORE_PATH . APP_DIR . DS);
 
+define('WWW_ROOT', ROOT . DS . 'webroot' . DS);
+define('CONFIG', dirname(__FILE__) . DS . 'config' . DS);
+
 require ROOT . '/vendor/cakephp/cakephp/src/basics.php';
 require ROOT . '/vendor/autoload.php';
 
-Cake\Core\Configure::write('App', ['namespace' => 'App']);
+Cake\Core\Configure::write('App', [
+		'namespace' => 'App',
+		'encoding' => 'UTF-8']);
+Cake\Core\Configure::write('debug', true);
+
+mb_internal_encoding('UTF-8');
 
-$Tmp = new \Cake\Utility\Folder(TMP);
+$Tmp = new \Cake\Filesystem\Folder(TMP);
 $Tmp->create(TMP . 'cache/models', 0770);
 $Tmp->create(TMP . 'cache/persistent', 0770);
 $Tmp->create(TMP . 'cache/views', 0770);

+ 12 - 0
tests/config/routes.php

@@ -0,0 +1,12 @@
+<?php
+namespace Tools\Test\App\Config;
+
+use Cake\Routing\Router;
+
+//Router::extensions(['rss']);
+
+Router::scope('/', function($routes) {
+	//$routes->extensions(['rss']);
+	$routes->connect('/:controller', ['action' => 'index'], ['routeClass' => 'InflectedRoute']);
+	$routes->connect('/:controller/:action/*', [], ['routeClass' => 'InflectedRoute']);
+});