diff --git a/lib/Cake/Controller/Component/RequestHandlerComponent.php b/lib/Cake/Controller/Component/RequestHandlerComponent.php index af285e040..6ca9b073f 100644 --- a/lib/Cake/Controller/Component/RequestHandlerComponent.php +++ b/lib/Cake/Controller/Component/RequestHandlerComponent.php @@ -95,7 +95,8 @@ class RequestHandlerComponent extends Component { * @param array $settings Array of settings. */ public function __construct(ComponentCollection $collection, $settings = array()) { - parent::__construct($collection, $settings); + $default = array('checkHttpCache' => true); + parent::__construct($collection, $settings + $default); $this->addInputType('xml', array(array($this, 'convertXml'))); $Controller = $collection->getController(); @@ -240,6 +241,22 @@ class RequestHandlerComponent extends Component { $this->_stop(); } +/** + * Checks if the response can be considered different according to the request + * headers, and the caching response headers. If it was not modified, then the + * render process is skipped. And the client will get a blank response with a + * "304 Not Modified" header. + * + * @params Controller $controller + * @return boolean false if the render process should be aborted + **/ + public function beforeRender($controller) { + $shouldCheck = $this->settings['checkHttpCache']; + if ($shouldCheck && $this->response->checkNotModified($this->request)) { + return false; + } + } + /** * Returns true if the current HTTP request is Ajax, false otherwise * @@ -704,4 +721,5 @@ class RequestHandlerComponent extends Component { } $this->_inputTypeMap[$type] = $handler; } + } diff --git a/lib/Cake/Controller/Controller.php b/lib/Cake/Controller/Controller.php index 50e467ab5..8b9549117 100644 --- a/lib/Cake/Controller/Controller.php +++ b/lib/Cake/Controller/Controller.php @@ -896,7 +896,12 @@ class Controller extends Object implements CakeEventListener { * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::render */ public function render($view = null, $layout = null) { - $this->getEventManager()->dispatch(new CakeEvent('Controller.beforeRender', $this)); + $event = new CakeEvent('Controller.beforeRender', $this); + $this->getEventManager()->dispatch($event); + if ($event->isStopped()) { + $this->autoRender = false; + return $this->response; + } $viewClass = $this->viewClass; if ($this->viewClass != 'View') { diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index be9ce5ae8..889f860fa 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -311,6 +311,14 @@ class CakeResponse { */ protected $_charset = 'UTF-8'; +/** + * Holds all the cache directives that will be converted + * into headers when sending the request + * + * @var string + */ + protected $_cacheDirectives = array(); + /** * Class constructor * @@ -348,14 +356,43 @@ class CakeResponse { $codeMessage = $this->_statusCodes[$this->_status]; $this->_sendHeader("{$this->_protocol} {$this->_status} {$codeMessage}"); - $this->_sendHeader('Content-Type', "{$this->_contentType}; charset={$this->_charset}"); + $this->_setContent(); $this->_setContentLength(); + $this->_setContentType(); foreach ($this->_headers as $header => $value) { $this->_sendHeader($header, $value); } $this->_sendContent($this->_body); } +/** + * Formats the Content-Type header based on the configured contentType and charset + * the charset will only be set in the header if the response is of type text/* + * + * @return void + */ + protected function _setContentType() { + if (in_array($this->_status, array(304, 204))) { + return; + } + if (strpos($this->_contentType, 'text/') === 0) { + $this->header('Content-Type', "{$this->_contentType}; charset={$this->_charset}"); + } else { + $this->header('Content-Type', "{$this->_contentType}"); + } + } + +/** + * Sets the response body to an empty text if the status code is 204 or 304 + * + * @return void + */ + protected function _setContent() { + if (in_array($this->_status, array(304, 204))) { + $this->body(''); + } + } + /** * Calculates the correct Content-Length and sets it as a header in the response * Will not set the value if already set or if the output is compressed. @@ -363,13 +400,17 @@ class CakeResponse { * @return void */ protected function _setContentLength() { - $shouldSetLength = empty($this->_headers['Content-Length']) && !in_array($this->_status, range(301, 307)); + $shouldSetLength = !isset($this->_headers['Content-Length']) && !in_array($this->_status, range(301, 307)); + if (isset($this->_headers['Content-Length']) && $this->_headers['Content-Length'] === false) { + unset($this->_headers['Content-Length']); + return; + } if ($shouldSetLength && !$this->outputCompressed()) { $offset = ob_get_level() ? ob_get_length() : 0; if (ini_get('mbstring.func_overload') & 2 && function_exists('mb_strlen')) { - $this->_headers['Content-Length'] = $offset + mb_strlen($this->_body, '8bit'); + $this->length($offset + mb_strlen($this->_body, '8bit')); } else { - $this->_headers['Content-Length'] = $offset + strlen($this->_body); + $this->length($this->_headers['Content-Length'] = $offset + strlen($this->_body)); } } } @@ -625,8 +666,7 @@ class CakeResponse { $this->header(array( 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', 'Last-Modified' => gmdate("D, d M Y H:i:s") . " GMT", - 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0', - 'Pragma' => 'no-cache' + 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0' )); } @@ -642,12 +682,272 @@ class CakeResponse { $time = strtotime($time); } $this->header(array( - 'Date' => gmdate("D, j M Y G:i:s ", time()) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Expires' => gmdate("D, j M Y H:i:s", $time) . " GMT", - 'Cache-Control' => 'public, max-age=' . ($time - time()), - 'Pragma' => 'cache' + 'Date' => gmdate("D, j M Y G:i:s ", time()) . 'GMT' )); + $this->modified($since); + $this->expires($time); + $this->sharable(true); + $this->maxAge($time - time()); + } + +/** + * Sets whether a response is eligible to be cached by intermediate proxies + * This method controls the `public` or `private` directive in the Cache-Control + * header + * + * @param boolean $public if set to true, the Cache-Control header will be set as public + * if set to false, the response will be set to private + * if no value is provided, it will return whether the response is sharable or not + * @param int $time time in seconds after which the response should no longer be considered fresh + * @return boolean + */ + public function sharable($public = null, $time = null) { + if ($public === null) { + $public = array_key_exists('public', $this->_cacheDirectives); + $private = array_key_exists('private', $this->_cacheDirectives); + $noCache = array_key_exists('no-cache', $this->_cacheDirectives); + if (!$public && !$private && !$noCache) { + return null; + } + $sharable = $public || ! ($private || $noCache); + return $sharable; + } + if ($public) { + $this->_cacheDirectives['public'] = true; + unset($this->_cacheDirectives['private']); + $this->sharedMaxAge($time); + } else { + $this->_cacheDirectives['private'] = true; + unset($this->_cacheDirectives['public']); + $this->maxAge($time); + } + if ($time == null) { + $this->_setCacheControl(); + } + return (bool) $public; + } + +/** + * Sets the Cache-Control s-maxage directive. + * The max-age is the number of seconds after which the response should no longer be considered + * a good candidate to be fetched from a shared cache (like in a proxy server). + * If called with no parameters, this function will return the current max-age value if any + * + * @param int $seconds if null, the method will return the current s-maxage value + * @return int + */ + public function sharedMaxAge($seconds = null) { + if ($seconds !== null) { + $this->_cacheDirectives['s-maxage'] = $seconds; + $this->_setCacheControl(); + } + if (isset($this->_cacheDirectives['s-maxage'])) { + return $this->_cacheDirectives['s-maxage']; + } + return null; + } + +/** + * Sets the Cache-Control max-age directive. + * The max-age is the number of seconds after which the response should no longer be considered + * a good candidate to be fetched from the local (client) cache. + * If called with no parameters, this function will return the current max-age value if any + * + * @param int $seconds if null, the method will return the current max-age value + * @return int + */ + public function maxAge($seconds = null) { + if ($seconds !== null) { + $this->_cacheDirectives['max-age'] = $seconds; + $this->_setCacheControl(); + } + if (isset($this->_cacheDirectives['max-age'])) { + return $this->_cacheDirectives['max-age']; + } + return null; + } + +/** + * Sets the Cache-Control must-revalidate directive. + * must-revalidate indicates that the response should not be served + * stale by a cache under any cirumstance without first revalidating + * with the origin. + * If called with no parameters, this function will return wheter must-revalidate is present. + * + * @param int $seconds if null, the method will return the current + * must-revalidate value + * @return boolean + */ + public function mustRevalidate($enable = null) { + if ($enable !== null) { + if ($enable) { + $this->_cacheDirectives['must-revalidate'] = true; + } else { + unset($this->_cacheDirectives['must-revalidate']); + } + $this->_setCacheControl(); + } + return array_key_exists('must-revalidate', $this->_cacheDirectives); + } + +/** + * Helper method to generate a valid Cache-Control header from the options set + * in other methods + * + * @return void + */ + protected function _setCacheControl() { + $control = ''; + foreach ($this->_cacheDirectives as $key => $val) { + $control .= $val === true ? $key : sprintf('%s=%s', $key, $val); + $control .= ', '; + } + $control = rtrim($control, ', '); + $this->header('Cache-Control', $control); + } + +/** + * Sets the Expires header for the response by taking an expiration time + * If called with no parameters it will return the current Expires value + * + * ## Examples: + * + * `$response->expires('now')` Will Expire the response cache now + * `$response->expires(new DateTime('+1 day'))` Will set the expiration in next 24 hours + * `$response->expires()` Will return the current expiration header value + * + * @param string|DateTime $time + * @return string + */ + public function expires($time = null) { + if ($time !== null) { + $date = $this->_getUTCDate($time); + $this->_headers['Expires'] = $date->format('D, j M Y H:i:s') . ' GMT'; + } + if (isset($this->_headers['Expires'])) { + return $this->_headers['Expires']; + } + return null; + } + +/** + * Sets the Last-Modified header for the response by taking an modification time + * If called with no parameters it will return the current Last-Modified value + * + * ## Examples: + * + * `$response->modified('now')` Will set the Last-Modified to the current time + * `$response->modified(new DateTime('+1 day'))` Will set the modification date in the past 24 hours + * `$response->modified()` Will return the current Last-Modified header value + * + * @param string|DateTime $time + * @return string + */ + public function modified($time = null) { + if ($time !== null) { + $date = $this->_getUTCDate($time); + $this->_headers['Last-Modified'] = $date->format('D, j M Y H:i:s') . ' GMT'; + } + if (isset($this->_headers['Last-Modified'])) { + return $this->_headers['Last-Modified']; + } + return null; + } + +/** + * Sets the response as Not Modified by removing any body contents + * setting the status code to "304 Not Modified" and removing all + * conflicting headers + * + * @return void + **/ + public function notModified() { + $this->statusCode(304); + $this->body(''); + $remove = array( + 'Allow', + 'Content-Encoding', + 'Content-Language', + 'Content-Length', + 'Content-MD5', + 'Content-Type', + 'Last-Modified' + ); + foreach ($remove as $header) { + unset($this->_headers[$header]); + } + } + +/** + * Sets the Vary header for the response, if an array is passed, + * values will be imploded into a comma separated string. If no + * parameters are passed, then an array with the current Vary header + * value is returned + * + * @param string|array $cacheVariances a single Vary string or a array + * containig the list for variances. + * @return array + **/ + public function vary($cacheVariances = null) { + if ($cacheVariances !== null) { + $cacheVariances = (array) $cacheVariances; + $this->_headers['Vary'] = implode(', ', $cacheVariances); + } + if (isset($this->_headers['Vary'])) { + return explode(', ', $this->_headers['Vary']); + } + return null; + } + +/** + * Sets the response Etag, Etags are a strong indicative that a response + * can be cached by a HTTP client. A bad way of generaing Etags is + * creating a hash of the response output, instead generate a unique + * hash of the unique components that identifies a request, such as a + * modification time, a resource Id, and anything else you consider it + * makes it unique. + * + * Second parameter is used to instuct clients that the content has + * changed, but sematicallly, it can be used as the same thing. Think + * for instance of a page with a hit counter, two different page views + * are equivalent, but they differ by a few bytes. This leaves off to + * the Client the decision of using or not the cached page. + * + * If no parameters are passed, current Etag header is returned. + * + * @param string $hash the unique has that identifies this resposnse + * @param boolean $weak whether the response is semantically the same as + * other with th same hash or not + * @return string + **/ + public function etag($tag = null, $weak = false) { + if ($tag !== null) { + $this->_headers['Etag'] = sprintf('%s"%s"', ($weak) ? 'W/' : null, $tag); + } + if (isset($this->_headers['Etag'])) { + return $this->_headers['Etag']; + } + return null; + } + + +/** + * Returns a DateTime object initialized at the $time param and using UTC + * as timezone + * + * @param string|int|DateTime $time + * @return DateTime + */ + protected function _getUTCDate($time = null) { + if ($time instanceof DateTime) { + $result = clone $time; + } else if (is_integer($time)) { + $result = new DateTime(date('Y-m-d H:i:s', $time)); + } else { + $result = new DateTime($time); + } + $result->setTimeZone(new DateTimeZone('UTC')); + return $result; } /** @@ -683,6 +983,68 @@ class CakeResponse { $this->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); } +/** + * Sets the protocol to be used when sending the response. Defaults to HTTP/1.1 + * If called with no arguments, it will return the current configured protocol + * + * @return string protocol to be used for sending response + */ + public function protocol($protocol = null) { + if ($protocol !== null) { + $this->_protocol = $protocol; + } + return $this->_protocol; + } + +/** + * Sets the Content-Length header for the response + * If called with no arguments returns the last Content-Length set + * + * @return int + */ + public function length($bytes = null) { + if ($bytes !== null ) { + $this->_headers['Content-Length'] = $bytes; + } + if (isset($this->_headers['Content-Length'])) { + return $this->_headers['Content-Length']; + } + return null; + } + +/** + * Checks whether a response has not been modified according to the 'If-None-Match' + * (Etags) and 'If-Modified-Since' (last modification date) request + * headers headers. If the response is detected to be not modified, it + * is marked as so accordingly so the client can be informed of that. + * + * In order to mark a response as not modified, you need to set at least + * the Last-Modified response header or a response etag to be compared + * with the request itself + * + * @return boolean whether the response was marked as not modified or + * not + **/ + public function checkNotModified(CakeRequest $request) { + $etags = preg_split('/\s*,\s*/', $request->header('If-None-Match'), null, PREG_SPLIT_NO_EMPTY); + $modifiedSince = $request->header('If-Modified-Since'); + if ($responseTag = $this->etag()) { + $etagMatches = in_array('*', $etags) || in_array($responseTag, $etags); + } + if ($modifiedSince) { + $timeMatches = strtotime($this->modified()) == strtotime($modifiedSince); + } + $checks = compact('etagMatches', 'timeMatches'); + if (empty($checks)) { + return false; + } + $notModified = !in_array(false, $checks, true); + if ($notModified) { + $this->notModified(); + } + return $notModified; + } + /** * String conversion. Fetches the response body as a string. * Does *not* send headers. diff --git a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php index 996dcd1fc..d36199e63 100644 --- a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php +++ b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php @@ -823,4 +823,60 @@ class RequestHandlerComponentTest extends CakeTestCase { public function testAddInputTypeException() { $this->RequestHandler->addInputType('csv', array('I am not callable')); } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagStar() { + $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something'); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertFalse($RequestHandler->beforeRender($this->Controller)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagExact() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertFalse($RequestHandler->beforeRender($this->Controller)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTime() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->modified('2012-01-01 00:00:00'); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertFalse($RequestHandler->beforeRender($this->Controller)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedNoInfo() { + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->expects($this->never())->method('notModified'); + $this->assertNull($RequestHandler->beforeRender($this->Controller)); + } } diff --git a/lib/Cake/Test/Case/Controller/ControllerTest.php b/lib/Cake/Test/Case/Controller/ControllerTest.php index fd31981c3..c5d85dcc6 100644 --- a/lib/Cake/Test/Case/Controller/ControllerTest.php +++ b/lib/Cake/Test/Case/Controller/ControllerTest.php @@ -359,6 +359,14 @@ class TestComponent extends Object { } } +class Test2Component extends TestComponent { + + + public function beforeRender($controller) { + return false; + } +} + /** * AnotherTestController class * @@ -685,6 +693,21 @@ class ControllerTest extends CakeTestCase { App::build(); } +/** + * test that a component beforeRender can change the controller view class. + * + * @return void + */ + public function testComponentCancelRender() { + $Controller = new Controller($this->getMock('CakeRequest'), new CakeResponse()); + $Controller->uses = array(); + $Controller->components = array('Test2'); + $Controller->constructClasses(); + $result = $Controller->render('index'); + $this->assertInstanceOf('CakeResponse', $result); + } + + /** * testToBeInheritedGuardmethods method * diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 33123dd62..872042896 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -17,6 +17,7 @@ * @license MIT License (http://www.opensource.org/licenses/mit-license.php) */ App::uses('CakeResponse', 'Network'); +App::uses('CakeRequest', 'Network'); class CakeResponseTest extends CakeTestCase { @@ -182,11 +183,13 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(2)) ->method('_sendHeader')->with('Content-Language', 'es'); - $response->expects($this->at(3)) + $response->expects($this->at(2)) ->method('_sendHeader')->with('WWW-Authenticate', 'Negotiate'); + $response->expects($this->at(3)) + ->method('_sendHeader')->with('Content-Length', 17); + $response->expects($this->at(4)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->send(); } @@ -202,7 +205,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'audio/mpeg; charset=UTF-8'); + ->method('_sendHeader')->with('Content-Length', 17); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'audio/mpeg'); $response->send(); } @@ -218,7 +223,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'audio/mpeg; charset=UTF-8'); + ->method('_sendHeader')->with('Content-Length', 17); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'audio/mpeg'); $response->send(); } @@ -232,9 +239,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 302 Found'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(2)) ->method('_sendHeader')->with('Location', 'http://www.example.com'); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->send(); } @@ -247,8 +254,7 @@ class CakeResponseTest extends CakeTestCase { $expected = array( 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', 'Last-Modified' => gmdate("D, d M Y H:i:s") . " GMT", - 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0', - 'Pragma' => 'no-cache' + 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0' ); $response->disableCache(); $this->assertEquals($response->header(), $expected); @@ -261,13 +267,13 @@ class CakeResponseTest extends CakeTestCase { public function testCache() { $response = new CakeResponse(); $since = time(); - $time = '+1 day'; + $time = new DateTime('+1 day', new DateTimeZone('UTC')); + $response->expires('+1 day'); $expected = array( 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Expires' => gmdate("D, j M Y H:i:s", strtotime($time)) . " GMT", - 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()), - 'Pragma' => 'cache' + 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', + 'Expires' => $time->format('D, j M Y H:i:s') . ' GMT', + 'Cache-Control' => 'public, max-age=' . ($time->format('U') - time()) ); $response->cache($since); $this->assertEquals($response->header(), $expected); @@ -277,10 +283,9 @@ class CakeResponseTest extends CakeTestCase { $time = '+5 day'; $expected = array( 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', + 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => gmdate("D, j M Y H:i:s", strtotime($time)) . " GMT", - 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()), - 'Pragma' => 'cache' + 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()) ); $response->cache($since, $time); $this->assertEquals($response->header(), $expected); @@ -290,10 +295,9 @@ class CakeResponseTest extends CakeTestCase { $time = time(); $expected = array( 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', + 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => gmdate("D, j M Y H:i:s", $time) . " GMT", - 'Cache-Control' => 'public, max-age=0', - 'Pragma' => 'cache' + 'Cache-Control' => 'public, max-age=0' ); $response->cache($since, $time); $this->assertEquals($response->header(), $expected); @@ -439,9 +443,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->once())->method('_sendContent')->with('the response body'); $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); + $response->expects($this->at(1)) ->method('_sendHeader')->with('Content-Length', strlen('the response body')); $response->send(); @@ -451,9 +455,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->once())->method('_sendContent')->with($body); $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); + $response->expects($this->at(1)) ->method('_sendHeader')->with('Content-Length', 116); $response->send(); @@ -471,7 +475,7 @@ class CakeResponseTest extends CakeTestCase { $response->header('Content-Length', 1); $response->expects($this->never())->method('outputCompressed'); $response->expects($this->once())->method('_sendContent')->with($body); - $response->expects($this->at(2)) + $response->expects($this->at(1)) ->method('_sendHeader')->with('Content-Length', 1); $response->send(); @@ -494,10 +498,427 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(2)) ->method('_sendHeader')->with('Content-Length', strlen($goofyOutput) + 116); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->send(); ob_end_clean(); } + +/** + * Tests getting/setting the protocol + * + * @return void + */ + public function testProtocol() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->protocol('HTTP/1.0'); + $this->assertEquals('HTTP/1.0', $response->protocol()); + $response->expects($this->at(0)) + ->method('_sendHeader')->with('HTTP/1.0 200 OK'); + $response->send(); + } + +/** + * Tests getting/setting the Content-Length + * + * @return void + */ + public function testLength() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->length(100); + $this->assertEquals(100, $response->length()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Content-Length', 100); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->length(false); + $this->assertFalse($response->length()); + $response->expects($this->exactly(2)) + ->method('_sendHeader'); + $response->send(); + } + +/** + * Tests that the response body is unset if the status code is 304 or 204 + * + * @return void + */ + public function testUnmodifiedContent() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('This is a body'); + $response->statusCode(204); + $response->expects($this->once()) + ->method('_sendContent')->with(''); + $response->send(); + $this->assertFalse(array_key_exists('Content-Type', $response->header())); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('This is a body'); + $response->statusCode(304); + $response->expects($this->once()) + ->method('_sendContent')->with(''); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('This is a body'); + $response->statusCode(200); + $response->expects($this->once()) + ->method('_sendContent')->with('This is a body'); + $response->send(); + } + +/** + * Tests setting the expiration date + * + * @return void + */ + public function testExpires() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); + $response->expires($now); + $now->setTimeZone(new DateTimeZone('UTC')); + $this->assertEquals($now->format('D, j M Y H:i:s') . ' GMT', $response->expires()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Expires', $now->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = time(); + $response->expires($now); + $this->assertEquals(gmdate('D, j M Y H:i:s', $now) . ' GMT', $response->expires()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Expires', gmdate('D, j M Y H:i:s', $now) . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $time = new DateTime('+1 day', new DateTimeZone('UTC')); + $response->expires('+1 day'); + $this->assertEquals($time->format('D, j M Y H:i:s') . ' GMT', $response->expires()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Expires', $time->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + } + +/** + * Tests setting the modification date + * + * @return void + */ + public function testModified() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); + $response->modified($now); + $now->setTimeZone(new DateTimeZone('UTC')); + $this->assertEquals($now->format('D, j M Y H:i:s') . ' GMT', $response->modified()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Last-Modified', $now->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = time(); + $response->modified($now); + $this->assertEquals(gmdate('D, j M Y H:i:s', $now) . ' GMT', $response->modified()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Last-Modified', gmdate('D, j M Y H:i:s', $now) . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $time = new DateTime('+1 day', new DateTimeZone('UTC')); + $response->modified('+1 day'); + $this->assertEquals($time->format('D, j M Y H:i:s') . ' GMT', $response->modified()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Last-Modified', $time->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + } + +/** + * Tests setting of public/private Cache-Control directives + * + * @return void + */ + public function testSharable() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertNull($response->sharable()); + $response->sharable(true); + $headers = $response->header(); + $this->assertEquals('public', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'public'); + $response->send(); + + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharable(false); + $headers = $response->header(); + $this->assertEquals('private', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'private'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharable(true); + $headers = $response->header(); + $this->assertEquals('public', $headers['Cache-Control']); + $response->sharable(false); + $headers = $response->header(); + $this->assertEquals('private', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'private'); + $response->send(); + $this->assertFalse($response->sharable()); + $response->sharable(true); + $this->assertTrue($response->sharable()); + + $response = new CakeResponse; + $response->sharable(true, 3600); + $headers = $response->header(); + $this->assertEquals('public, s-maxage=3600', $headers['Cache-Control']); + + $response = new CakeResponse; + $response->sharable(false, 3600); + $headers = $response->header(); + $this->assertEquals('private, max-age=3600', $headers['Cache-Control']); + $response->send(); + } + +/** + * Tests setting of max-age Cache-Control directive + * + * @return void + */ + public function testMaxAge() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertNull($response->maxAge()); + $response->maxAge(3600); + $this->assertEquals(3600, $response->maxAge()); + $headers = $response->header(); + $this->assertEquals('max-age=3600', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'max-age=3600'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->maxAge(3600); + $response->sharable(false); + $headers = $response->header(); + $this->assertEquals('max-age=3600, private', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'max-age=3600, private'); + $response->send(); + } + +/** + * Tests setting of s-maxage Cache-Control directive + * + * @return void + */ + public function testSharedMaxAge() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertNull($response->maxAge()); + $response->sharedMaxAge(3600); + $this->assertEquals(3600, $response->sharedMaxAge()); + $headers = $response->header(); + $this->assertEquals('s-maxage=3600', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharedMaxAge(3600); + $response->sharable(true); + $headers = $response->header(); + $this->assertEquals('s-maxage=3600, public', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600, public'); + $response->send(); + } + +/** + * Tests setting of must-revalidate Cache-Control directive + * + * @return void + */ + public function testMustRevalidate() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertFalse($response->mustRevalidate()); + $response->mustRevalidate(true); + $this->assertTrue($response->mustRevalidate()); + $headers = $response->header(); + $this->assertEquals('must-revalidate', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'must-revalidate'); + $response->send(); + $response->mustRevalidate(false); + $this->assertFalse($response->mustRevalidate()); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharedMaxAge(3600); + $response->mustRevalidate(true); + $headers = $response->header(); + $this->assertEquals('s-maxage=3600, must-revalidate', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600, must-revalidate'); + $response->send(); + + } + +/** + * Tests getting/setting the Vary header + * + * @return void + */ + public function testVary() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->vary('Accept-encoding'); + $this->assertEquals(array('Accept-encoding'), $response->vary()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Vary', 'Accept-encoding'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->vary(array('Accept-language', 'Accept-encoding')); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Vary', 'Accept-language, Accept-encoding'); + $response->send(); + $this->assertEquals(array('Accept-language', 'Accept-encoding'), $response->vary()); + } + +/** + * Tests getting/setting the Etag header + * + * @return void + */ + public function testEtag() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->etag('something'); + $this->assertEquals('"something"', $response->etag()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Etag', '"something"'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->etag('something', true); + $this->assertEquals('W/"something"', $response->etag()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Etag', 'W/"something"'); + $response->send(); + } + +/** + * Tests that the response is able to be marked as not modified + * + * @return void + */ + public function testNotModified() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('something'); + $response->statusCode(200); + $response->length(100); + $response->modified('now'); + $response->notModified(); + + $this->assertEmpty($response->header()); + $this->assertEmpty($response->body()); + $this->assertEquals(304, $response->statusCode()); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagStar() { + $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something'); + $response->expects($this->once())->method('notModified'); + $response->checkNotModified(new CakeRequest); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagExact() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->expects($this->once())->method('notModified'); + $this->assertTrue($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTime() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->modified('2012-01-01 00:00:00'); + $response->expects($this->once())->method('notModified'); + $this->assertTrue($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTimeMismatch() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->modified('2012-01-01 00:00:01'); + $response->expects($this->never())->method('notModified'); + $this->assertFalse($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagMismatch() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something-else", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->modified('2012-01-01 00:00:00'); + $response->expects($this->never())->method('notModified'); + $this->assertFalse($response->checkNotModified(new CakeRequest)); + } + + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByTime() { + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->modified('2012-01-01 00:00:00'); + $response->expects($this->once())->method('notModified'); + $this->assertTrue($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedNoHints() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->expects($this->never())->method('notModified'); + $this->assertFalse($response->checkNotModified(new CakeRequest)); + } } diff --git a/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php b/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php index ae45bedf1..3ecd200a4 100644 --- a/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php +++ b/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php @@ -48,51 +48,59 @@ class RedirectRouteTestCase extends CakeTestCase { $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/home'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts', true)); $route = new RedirectRoute('/home', array('controller' => 'posts', 'action' => 'index')); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/home'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts', true)); $this->assertEquals($route->response->statusCode(), 301); $route = new RedirectRoute('/google', 'http://google.com'); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/google'); - $this->assertEquals($route->response->header(), array('Location' => 'http://google.com')); + $header = $route->response->header(); + $this->assertEquals($header['Location'], 'http://google.com'); $route = new RedirectRoute('/posts/*', array('controller' => 'posts', 'action' => 'view'), array('status' => 302)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/posts/2'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts/view', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts/view', true)); $this->assertEquals($route->response->statusCode(), 302); $route = new RedirectRoute('/posts/*', array('controller' => 'posts', 'action' => 'view'), array('persist' => true)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/posts/2'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts/view/2', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts/view/2', true)); $route = new RedirectRoute('/posts/*', '/test', array('persist' => true)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/posts/2'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/test', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/test', true)); $route = new RedirectRoute('/my_controllers/:action/*', array('controller' => 'tags', 'action' => 'add'), array('persist' => true)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/my_controllers/do_something/passme/named:param'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/tags/add/passme/named:param', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/tags/add/passme/named:param', true)); $route = new RedirectRoute('/my_controllers/:action/*', array('controller' => 'tags', 'action' => 'add')); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/my_controllers/do_something/passme/named:param'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/tags/add', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/tags/add', true)); } } diff --git a/lib/Cake/Test/Case/Routing/RouterTest.php b/lib/Cake/Test/Case/Routing/RouterTest.php index 5d7c4d6ae..a5946bb7a 100644 --- a/lib/Cake/Test/Case/Routing/RouterTest.php +++ b/lib/Cake/Test/Case/Routing/RouterTest.php @@ -2519,7 +2519,8 @@ class RouterTest extends CakeTestCase { $this->assertEquals(Router::$routes[0]->options['status'], 302); Router::parse('/blog'); - $this->assertEquals(Router::$routes[0]->response->header(), array('Location' => Router::url('/posts', true))); + $header = Router::$routes[0]->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts', true)); $this->assertEquals(Router::$routes[0]->response->statusCode(), 302); Router::$routes[0]->response = $this->getMock('CakeResponse', array('_sendHeader'));