From 9f689e97dae0055612ad39c623ae4ead42f8cc91 Mon Sep 17 00:00:00 2001 From: David Buchmann Date: Sun, 2 Aug 2015 13:18:57 +0200 Subject: [PATCH] Provide Symfony HttpCache as trait --- doc/symfony-cache-configuration.rst | 137 +++--- src/SymfonyCache/DebugListener.php | 64 +++ .../EventDispatchingHttpCache.php | 86 ++-- .../EventDispatchingHttpCacheTestCase.php | 416 ++++++++++++++++++ src/Test/PHPUnit/AbstractCacheConstraint.php | 22 +- .../Functional/Fixtures/Symfony/AppCache.php | 42 +- tests/Functional/Fixtures/web/symfony.php | 6 - tests/Unit/SymfonyCache/DebugListenerTest.php | 99 +++++ .../EventDispatchingHttpCacheTest.php | 176 +------- .../Unit/SymfonyCache/PurgeSubscriberTest.php | 4 +- .../SymfonyCache/RefreshSubscriberTest.php | 4 +- .../UserContextSubscriberTest.php | 4 +- .../Test/PHPUnit/IsCacheHitConstraintTest.php | 4 +- 13 files changed, 767 insertions(+), 297 deletions(-) create mode 100644 src/SymfonyCache/DebugListener.php create mode 100644 src/Test/EventDispatchingHttpCacheTestCase.php create mode 100644 tests/Unit/SymfonyCache/DebugListenerTest.php diff --git a/doc/symfony-cache-configuration.rst b/doc/symfony-cache-configuration.rst index 887b5fe2..4c0d618d 100644 --- a/doc/symfony-cache-configuration.rst +++ b/doc/symfony-cache-configuration.rst @@ -9,61 +9,101 @@ than using Varnish or NGINX, it can still provide considerable performance gains over an installation that is not cached at all. It can be useful for running an application on shared hosting for instance. -You can use features of this library with the help of the -``EventDispatchingHttpCache`` provided here. The basic concept is to use event -subscribers on the HttpCache class. +You can use features of this library with the help of event listeners that act +on events of the ``HttpCache``. The Symfony ``HttpCache`` does not have an +event system, for this you need to use the trait ``EventDispatchingHttpCache`` +provided by this library. The event listeners handle the requests from the +:doc:`proxy-clients`. -.. warning:: +.. note:: - If you are using the full stack Symfony framework, have a look at the - HttpCache provided by the FOSHttpCacheBundle_ instead. + Symfony ``HttpCache`` does not currently provide support for banning. + +Using the trait +~~~~~~~~~~~~~~~ .. note:: - Symfony HttpCache does not currently provide support for banning. + The trait is available since version 2.0.0. Version 1.* of this library + instead provided a base ``HttpCache`` class to extend. + +Your ``AppCache`` needs to implement ``CacheInvalidationInterface`` and use the +trait ``FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache``:: + + use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; + use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache; + use Symfony\Component\HttpFoundation\Request; + use Symfony\Component\HttpKernel\HttpCache\HttpCache; + + class AppCache extends HttpCache implements CacheInvalidationInterface + { + use EventDispatchingHttpCache; -Extending the Correct HttpCache Class -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + /** + * Made public to allow event subscribers to do refresh operations. + * + * {@inheritDoc} + */ + public function fetch(Request $request, $catch = false) + { + return parent::fetch($request, $catch); + } + } -Instead of extending ``Symfony\Component\HttpKernel\HttpCache\HttpCache``, your -``AppCache`` should extend ``FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache``. +The trait is adding events before and/or after kernel methods to let the +listeners interfere. If you need to overwrite core ``HttpCache`` functionality +in your kernel, one option is to provide your own event listeners. If you need +to implement functionality directly on the methods, be careful to always call +the trait methods rather than going directly to the parent, or events will not +be triggered anymore. You might also need to copy a method from the trait and +add your own logic between the events to not be too early or too late for the +event. -.. tip:: +When starting to extend your ``AppCache``, it is recommended to use the +``EventDispatchingHttpCacheTestCase`` to run tests with your kernel to be sure +all events are triggered as expected. - If your class already needs to extend a different class, simply copy the - event handling code from the EventDispatchingHttpCache into your - ``AppCache`` class and make it implement ``CacheInvalidationInterface``. - The drawback is that you need to manually check whether you need to adjust - your ``AppCache`` each time you update the FOSHttpCache library. +Cache event listeners +~~~~~~~~~~~~~~~~~~~~~ Now that you have an event dispatching kernel, you can make it register the -subscribers you need. While you could do that from your bootstrap code, this is +listeners you need. While you could do that from your bootstrap code, this is not the recommended way. You would need to adjust every place you instantiate -the cache. Instead, overwrite the constructor of AppCache and register the -subscribers there. A simple cache will look like this:: +the cache. Instead, overwrite the constructor of your ``AppCache`` and register +the listeners you need there:: - use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache; + use FOS\HttpCache\SymfonyCache\DebugListener(); + use FOS\HttpCache\SymfonyCache\CustomTtlListener(); use FOS\HttpCache\SymfonyCache\PurgeSubscriber; use FOS\HttpCache\SymfonyCache\RefreshSubscriber; use FOS\HttpCache\SymfonyCache\UserContextSubscriber; - use FOS\HttpCache\SymfonyCache\CustomTtlListener(); - - class AppCache extends EventDispatchingHttpCache - { - /** - * Overwrite constructor to register event subscribers for FOSHttpCache. - */ - public function __construct(HttpKernelInterface $kernel, $cacheDir = null) - { - parent::__construct($kernel, $cacheDir); - $this->addSubscriber(new PurgeSubscriber()); - $this->addSubscriber(new RefreshSubscriber()); - $this->addSubscriber(new UserContextSubscriber()); - $this->addSubscriber(new CustomTtlListener()); + // ... + + /** + * Overwrite constructor to register event subscribers for FOSHttpCache. + */ + public function __construct( + HttpKernelInterface $kernel, + StoreInterface $store, + SurrogateInterface $surrogate = null, + array $options = array() + ) { + parent::__construct($kernel, $store, $surrogate, $options); + + $this->addSubscriber(new CustomTtlListener()); + $this->addSubscriber(new PurgeSubscriber()); + $this->addSubscriber(new RefreshSubscriber()); + $this->addSubscriber(new UserContextSubscriber()); + if (isset($options['debug']) && $options['debug']) { + $this->addSubscriber(new DebugListener()); } } +The event listeners can be tweaked by passing options to the constructor. The +Symfony configuration system does not work here because things in the cache +happen before the configuration is loaded. + Purge ~~~~~ @@ -204,28 +244,11 @@ Debugging ~~~~~~~~~ For the ``assertHit`` and ``assertMiss`` assertions to work, you need to add -debug information in your AppCache. Create the cache kernel with the option -``'debug' => true`` and add the following to your ``AppCache``:: - - public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) - { - $response = parent::handle($request, $type, $catch); - - if ($response->headers->has('X-Symfony-Cache')) { - if (false !== strpos($response->headers->get('X-Symfony-Cache'), 'miss')) { - $state = 'MISS'; - } elseif (false !== strpos($response->headers->get('X-Symfony-Cache'), 'fresh')) { - $state = 'HIT'; - } else { - $state = 'UNDETERMINED'; - } - $response->headers->set('X-Cache', $state); - } - - return $response; - } +debug information in your AppCache. When running the tests, create the cache +kernel with the option ``'debug' => true`` and add the ``DebugListener``. -The ``UNDETERMINED`` state should never happen. If it does, it means that your -HttpCache is not correctly set into debug mode. +The ``UNDETERMINED`` state should never happen. If it does, it means that +something went really wrong in the kernel. Have a look at ``X-Symfony-Cache`` +and at the HTML body of the response. .. _HttpCache: http://symfony.com/doc/current/book/http_cache.html#symfony-reverse-proxy diff --git a/src/SymfonyCache/DebugListener.php b/src/SymfonyCache/DebugListener.php new file mode 100644 index 00000000..29fb2c28 --- /dev/null +++ b/src/SymfonyCache/DebugListener.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\SymfonyCache; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestMatcher; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Debug handler for the symfony built-in HttpCache. + * + * Add debug information to the response for use in cache tests. + * + * @author David Buchmann + * + * {@inheritdoc} + */ +class DebugListener implements EventSubscriberInterface +{ + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() + { + return [ + Events::POST_HANDLE => 'handleDebug', + ]; + } + + /** + * Extract the cache HIT/MISS information from the X-Symfony-Cache header. + * + * For this header to be present, the HttpCache must be created with the + * debug option set to true. + * + * @param CacheEvent $event + */ + public function handleDebug(CacheEvent $event) + { + $response = $event->getResponse(); + if ($response->headers->has('X-Symfony-Cache')) { + if (false !== strpos($response->headers->get('X-Symfony-Cache'), 'miss')) { + $state = 'MISS'; + } elseif (false !== strpos($response->headers->get('X-Symfony-Cache'), 'fresh')) { + $state = 'HIT'; + } else { + $state = 'UNDETERMINED'; + } + $response->headers->set('X-Cache', $state); + } + } +} diff --git a/src/SymfonyCache/EventDispatchingHttpCache.php b/src/SymfonyCache/EventDispatchingHttpCache.php index eac9b8b2..1c7720c6 100644 --- a/src/SymfonyCache/EventDispatchingHttpCache.php +++ b/src/SymfonyCache/EventDispatchingHttpCache.php @@ -11,27 +11,36 @@ namespace FOS\HttpCache\SymfonyCache; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\HttpCache\HttpCache; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; /** - * Base class for enhanced Symfony reverse proxy based on the symfony component. + * Trait for enhanced Symfony reverse proxy based on the symfony kernel component. * - * When using FOSHttpCacheBundle, look at FOS\HttpCacheBundle\HttpCache instead. + * Your kernel needs to implement CacheInvalidatorInterface and redeclare the + * fetch method as public. (The latter is needed because the trait declaring it + * public does not satisfy the interface for whatever reason. See also + * http://stackoverflow.com/questions/31877844/php-trait-exposing-a-method-and-interfaces ) * - * This kernel supports event subscribers that can act on the events defined in - * FOS\HttpCache\SymfonyCache\Events and may alter the request flow. + * CacheInvalidator kernels support event subscribers that can act on the + * events defined in FOS\HttpCache\SymfonyCache\Events and may alter the + * request flow. + * + * If your kernel overwrites any of the methods defined in this trait, make + * sure to also call the trait method. You might get into issues with the order + * of events, in which case you will need to copy event triggering into your + * kernel. * * @author Jérôme Vieilledent (courtesy of eZ Systems AS) + * @author David Buchmann * * {@inheritdoc} */ -abstract class EventDispatchingHttpCache extends HttpCache implements CacheInvalidationInterface +trait EventDispatchingHttpCache { /** * @var EventDispatcherInterface @@ -69,17 +78,13 @@ public function addSubscriber(EventSubscriberInterface $subscriber) */ public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) { - if ($this->getEventDispatcher()->hasListeners(Events::PRE_HANDLE)) { - $event = new CacheEvent($this, $request); - $this->getEventDispatcher()->dispatch(Events::PRE_HANDLE, $event); - if ($event->getResponse()) { - return $this->dispatchPostHandle($request, $event->getResponse()); - } + if ($response = $this->dispatch(Events::PRE_HANDLE, $request)) { + return $this->dispatch(Events::POST_HANDLE, $request, $response); } $response = parent::handle($request, $type, $catch); - return $this->dispatchPostHandle($request, $response); + return $this->dispatch(Events::POST_HANDLE, $request, $response); } /** @@ -89,59 +94,42 @@ public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQ */ protected function store(Request $request, Response $response) { - if ($this->getEventDispatcher()->hasListeners(Events::PRE_STORE)) { - $event = new CacheEvent($this, $request, $response); - $this->getEventDispatcher()->dispatch(Events::PRE_STORE, $event); - $response = $event->getResponse(); - } + $response = $this->dispatch(Events::PRE_STORE, $request, $response); parent::store($request, $response); } /** - * Dispatch the POST_HANDLE event if needed. - * - * @param Request $request - * @param Response $response + * {@inheritDoc} * - * @return Response The response to return which might be altered by a POST_HANDLE listener. + * Adding the Events::PRE_INVALIDATE event. */ - private function dispatchPostHandle(Request $request, Response $response) + protected function invalidate(Request $request, $catch = false) { - if ($this->getEventDispatcher()->hasListeners(Events::POST_HANDLE)) { - $event = new CacheEvent($this, $request, $response); - $this->getEventDispatcher()->dispatch(Events::POST_HANDLE, $event); - $response = $event->getResponse(); + if ($response = $this->dispatch(Events::PRE_INVALIDATE, $request)) { + return $response; } - return $response; + return parent::invalidate($request, $catch); } /** - * Made public to allow event subscribers to do refresh operations. + * Dispatch an event if needed. * - * {@inheritDoc} - */ - public function fetch(Request $request, $catch = false) - { - return parent::fetch($request, $catch); - } - - /** - * {@inheritDoc} + * @param string $name Name of the event to trigger. One of the constants in FOS\HttpCache\SymfonyCache\Events + * @param Request $request + * @param Response|null $response If already available * - * Adding the Events::PRE_INVALIDATE event. + * @return Response The response to return, which might be provided/altered by a listener. */ - protected function invalidate(Request $request, $catch = false) + protected function dispatch($name, Request $request, Response $response = null) { - if ($this->getEventDispatcher()->hasListeners(Events::PRE_INVALIDATE)) { - $event = new CacheEvent($this, $request); - $this->getEventDispatcher()->dispatch(Events::PRE_INVALIDATE, $event); - if ($event->getResponse()) { - return $event->getResponse(); - } + if ($this->getEventDispatcher()->hasListeners($name)) { + $event = new CacheEvent($this, $request, $response); + $this->getEventDispatcher()->dispatch($name, $event); + $response = $event->getResponse(); } - return parent::invalidate($request, $catch); + return $response; } } diff --git a/src/Test/EventDispatchingHttpCacheTestCase.php b/src/Test/EventDispatchingHttpCacheTestCase.php new file mode 100644 index 00000000..835c18c0 --- /dev/null +++ b/src/Test/EventDispatchingHttpCacheTestCase.php @@ -0,0 +1,416 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\Test; + +use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; +use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache; +use FOS\HttpCache\SymfonyCache\CacheEvent; +use FOS\HttpCache\SymfonyCache\Events; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * This test ensures that the EventDispatchingHttpCache trait is correctly used. + */ +abstract class EventDispatchingHttpCacheTestCase extends \PHPUnit_Framework_TestCase +{ + /** + * Specify the CacheInvalidationInterface HttpCache class to test. + * + * @return string Fully qualified class name of the AppCache + */ + abstract protected function getCacheClass(); + + /** + * Create a partial mock of the HttpCache to only test some methods. + * + * @param array $mockedMethods List of methods to mock. + * + * @return CacheInvalidationInterface|EventDispatchingHttpCache|\PHPUnit_Framework_MockObject_MockObject + */ + protected function getHttpCachePartialMock(array $mockedMethods = null) + { + $mock = $this + ->getMockBuilder($this->getCacheClass()) + ->setMethods( $mockedMethods ) + ->disableOriginalConstructor() + ->getMock() + ; + + $this->assertInstanceOf('FOS\HttpCache\SymfonyCache\CacheInvalidationInterface', $mock); + + // Force setting options property since we can't use original constructor. + $options = [ + 'debug' => false, + 'default_ttl' => 0, + 'private_headers' => [ 'Authorization', 'Cookie' ], + 'allow_reload' => false, + 'allow_revalidate' => false, + 'stale_while_revalidate' => 2, + 'stale_if_error' => 60, + ]; + + $refHttpCache = new \ReflectionClass('Symfony\Component\HttpKernel\HttpCache\HttpCache'); + // Workaround for Symfony 2.3 where $options property is not defined. + if (!$refHttpCache->hasProperty('options')) { + $mock->options = $options; + } else { + $refOptions = $refHttpCache->getProperty('options'); + $refOptions->setAccessible(true); + $refOptions->setValue($mock, $options ); + } + + return $mock; + } + + /** + * Set the store property on a HttpCache to a StoreInterface expecting one write with request and response. + * + * @param CacheInvalidationInterface $httpCache + * @param Request $request + * @param Response $response + */ + protected function setStoreMock(CacheInvalidationInterface $httpCache, Request $request, Response $response) + { + $store = $this->getMock('Symfony\Component\HttpKernel\HttpCache\StoreInterface'); + $store + ->expects($this->once()) + ->method('write') + ->with($request, $response) + ; + $refHttpCache = new \ReflectionClass('Symfony\Component\HttpKernel\HttpCache\HttpCache'); + $refStore = $refHttpCache->getProperty('store'); + $refStore->setAccessible(true); + $refStore->setValue($httpCache, $store); + } + + /** + * Assert that preHandle and postHandle are called. + */ + public function testHandleCalled() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $response = new Response(); + + $httpCache = $this->getHttpCachePartialMock(['lookup']); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->any()) + ->method('lookup') + ->with($request) + ->will($this->returnValue($response)) + ; + + $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + $this->assertEquals(1, $subscriber->preHandleCalls); + $this->assertEquals(1, $subscriber->postHandleCalls); + } + + /** + * Assert that when preHandle returns a response, that response is used and the normal kernel flow stopped. + * + * @depends testHandleCalled + */ + public function testPreHandleReturnEarly() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $response = new Response(); + + $httpCache = $this->getHttpCachePartialMock(['lookup']); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $subscriber->preHandleResponse = $response; + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->never()) + ->method('lookup') + ; + + $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + $this->assertEquals(1, $subscriber->preHandleCalls); + $this->assertEquals(1, $subscriber->postHandleCalls); + } + + /** + * Assert that postHandle can update the response. + * + * @depends testHandleCalled + */ + public function testPostHandleReturn() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $regularResponse = new Response(); + $postResponse = new Response(); + + $httpCache = $this->getHttpCachePartialMock(['lookup']); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $subscriber->postHandleResponse = $postResponse; + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->any()) + ->method('lookup') + ->with($request) + ->will($this->returnValue($regularResponse)) + ; + + $this->assertSame($postResponse, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + $this->assertEquals(1, $subscriber->preHandleCalls); + $this->assertEquals(1, $subscriber->postHandleCalls); + } + + /** + * Assert that postHandle is called and the response can be updated even when preHandle returned a response. + * + * @depends testHandleCalled + */ + public function testPostHandleAfterPreHandle() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $preResponse = new Response(); + $postResponse = new Response(); + + $httpCache = $this->getHttpCachePartialMock(['lookup']); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $subscriber->preHandleResponse = $preResponse; + $subscriber->postHandleResponse = $postResponse; + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->never()) + ->method('lookup') + ; + + $this->assertSame($postResponse, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + $this->assertEquals(1, $subscriber->preHandleCalls); + $this->assertEquals(1, $subscriber->postHandleCalls); + } + + /** + * Assert that preStore is called. + */ + public function testPreStoreCalled() + { + $request = Request::create('/foo', 'GET'); + $response = new Response(); + + $httpCache = $this->getHttpCachePartialMock(); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $httpCache->addSubscriber($subscriber); + + $this->setStoreMock($httpCache, $request, $response); + + $refHttpCache = new \ReflectionObject($httpCache); + $method = $refHttpCache->getMethod('store'); + $method->setAccessible(true); + $method->invokeArgs($httpCache, [$request, $response]); + $this->assertEquals(1, $subscriber->preStoreCalls); + } + + /** + * Assert that preStore response is used when provided. + */ + public function testPreStoreResponse() + { + $request = Request::create('/foo', 'GET'); + $regularResponse = new Response(); + $preStoreResponse = new Response(); + + $httpCache = $this->getHttpCachePartialMock(); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $subscriber->preStoreResponse = $preStoreResponse; + $httpCache->addSubscriber($subscriber); + + $this->setStoreMock($httpCache, $request, $preStoreResponse); + + $refHttpCache = new \ReflectionObject($httpCache); + $method = $refHttpCache->getMethod('store'); + $method->setAccessible(true); + $method->invokeArgs($httpCache, [$request, $regularResponse]); + $this->assertEquals(1, $subscriber->preStoreCalls); + } + + /** + * Assert that preInvalidate is called. + */ + public function testPreInvalidateCalled() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $response = new Response('', 500); + + $httpCache = $this->getHttpCachePartialMock(['pass']); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->any()) + ->method('pass') + ->with($request) + ->will($this->returnValue($response)) + ; + $refHttpCache = new \ReflectionObject($httpCache); + $method = $refHttpCache->getMethod('invalidate'); + $method->setAccessible(true); + + $this->assertSame($response, $method->invokeArgs($httpCache, [$request, $catch])); + $this->assertEquals(1, $subscriber->preInvalidateCalls); + } + + /** + * Assert that when preInvalidate returns a response, that response is used and the normal kernel flow stopped. + * + * @depends testPreInvalidateCalled + */ + public function testPreInvalidateReturnEarly() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $response = new Response('', 400); + + $httpCache = $this->getHttpCachePartialMock(['pass']); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $subscriber->preInvalidateResponse = $response; + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->never()) + ->method('pass') + ; + $refHttpCache = new \ReflectionObject($httpCache); + $method = $refHttpCache->getMethod('invalidate'); + $method->setAccessible(true); + + $this->assertSame($response, $method->invokeArgs($httpCache, [$request, $catch])); + $this->assertEquals(1, $subscriber->preInvalidateCalls); + } +} + +class TestSubscriber implements EventSubscriberInterface +{ + /** + * @var int Count how many times preHandle has been called. + */ + public $preHandleCalls = 0; + + /** + * @var int Count how many times postHandle has been called. + */ + public $postHandleCalls = 0; + + /** + * @var int Count how many times preStore has been called. + */ + public $preStoreCalls = 0; + + /** + * @var int Count how many times preInvalidate has been called. + */ + public $preInvalidateCalls = 0; + + /** + * @var Response A response to set during the preHandle + */ + public $preHandleResponse; + + /** + * @var Response A response to set during the postHandle + */ + public $postHandleResponse; + + /** + * @var Response A response to set during the preStore + */ + public $preStoreResponse; + + /** + * @var Response A response to set during the preInvalidate + */ + public $preInvalidateResponse; + + /** + * @var EventDispatchingHttpCacheTestCase To do assertions + */ + private $test; + + /** + * @var CacheInvalidationInterface The kernel to ensure the event carries the correct kernel + */ + private $kernel; + + /** + * @var Request The request to ensure the event carries the correct request + */ + private $request; + + public function __construct( + EventDispatchingHttpCacheTestCase $test, + CacheInvalidationInterface $kernel, + Request $request + ) { + $this->test = $test; + $this->kernel = $kernel; + $this->request = $request; + } + + public static function getSubscribedEvents() + { + return [ + Events::PRE_HANDLE => 'preHandle', + Events::POST_HANDLE => 'postHandle', + Events::PRE_STORE => 'preStore', + Events::PRE_INVALIDATE => 'preInvalidate', + ]; + } + + public function preHandle(CacheEvent $event) + { + $this->test->assertSame($this->kernel, $event->getKernel()); + $this->test->assertSame($this->request, $event->getRequest()); + if ($this->preHandleResponse) { + $event->setResponse($this->preHandleResponse); + } + $this->preHandleCalls++; + } + + public function postHandle(CacheEvent $event) + { + $this->test->assertSame($this->kernel, $event->getKernel()); + $this->test->assertSame($this->request, $event->getRequest()); + if ($this->postHandleResponse) { + $event->setResponse($this->postHandleResponse); + } + $this->postHandleCalls++; + } + + public function preStore(CacheEvent $event) + { + $this->test->assertSame($this->kernel, $event->getKernel()); + $this->test->assertSame($this->request, $event->getRequest()); + if ($this->preStoreResponse) { + $event->setResponse($this->preStoreResponse); + } + $this->preStoreCalls++; + } + + public function preInvalidate(CacheEvent $event) + { + $this->test->assertSame($this->kernel, $event->getKernel()); + $this->test->assertSame($this->request, $event->getRequest()); + if ($this->preInvalidateResponse) { + $event->setResponse($this->preInvalidateResponse); + } + $this->preInvalidateCalls++; + } +} diff --git a/src/Test/PHPUnit/AbstractCacheConstraint.php b/src/Test/PHPUnit/AbstractCacheConstraint.php index b3496de8..43abcd2a 100644 --- a/src/Test/PHPUnit/AbstractCacheConstraint.php +++ b/src/Test/PHPUnit/AbstractCacheConstraint.php @@ -11,6 +11,8 @@ namespace FOS\HttpCache\Test\PHPUnit; +use Psr\Http\Message\ResponseInterface; + /** * Abstract cache constraint */ @@ -41,17 +43,25 @@ abstract public function getValue(); /** * {@inheritdoc} + * + * @param Response $other The guzzle response object */ protected function matches($other) { + if (!$other instanceof ResponseInterface) { + throw new \RuntimeException(sprintf('Expected a GuzzleHttp\Psr7\Response but got %s', get_class($other))); + } if (!$other->hasHeader($this->header)) { - throw new \RuntimeException( - sprintf( - 'Response has no "%s" header. Configure your caching proxy ' - . 'to set the header with cache hit/miss status.', - $this->header - ) + $message = sprintf( + 'Response has no "%s" header. Configure your caching proxy ' + . 'to set the header with cache hit/miss status.', + $this->header ); + if (200 !== $other->getStatusCode()) { + $message .= sprintf("\nStatus code of response is %s.", $other->getStatusCode()); + } + + throw new \RuntimeException($message); } return $this->getValue() === (string) $other->getHeaderLine($this->header); diff --git a/tests/Functional/Fixtures/Symfony/AppCache.php b/tests/Functional/Fixtures/Symfony/AppCache.php index 090fa872..bb7e169b 100644 --- a/tests/Functional/Fixtures/Symfony/AppCache.php +++ b/tests/Functional/Fixtures/Symfony/AppCache.php @@ -2,27 +2,43 @@ namespace FOS\HttpCache\Tests\Functional\Fixtures\Symfony; +use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; +use FOS\HttpCache\SymfonyCache\CustomTtlListener; use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\HttpCache\HttpCache; +use FOS\HttpCache\SymfonyCache\DebugListener; +use FOS\HttpCache\SymfonyCache\PurgeSubscriber; +use FOS\HttpCache\SymfonyCache\RefreshSubscriber; +use FOS\HttpCache\SymfonyCache\UserContextSubscriber; +use Symfony\Component\HttpKernel\HttpCache\StoreInterface; +use Symfony\Component\HttpKernel\HttpCache\SurrogateInterface; use Symfony\Component\HttpKernel\HttpKernelInterface; -class AppCache extends EventDispatchingHttpCache +class AppCache extends HttpCache implements CacheInvalidationInterface { - public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) + use EventDispatchingHttpCache; + + public function __construct(HttpKernelInterface $kernel, StoreInterface $store, SurrogateInterface $surrogate = null, array $options = array()) { - $response = parent::handle($request, $type, $catch); + parent::__construct($kernel, $store, $surrogate, $options); - if ($response->headers->has('X-Symfony-Cache')) { - if (false !== strpos($response->headers->get('X-Symfony-Cache'), 'miss')) { - $state = 'MISS'; - } elseif (false !== strpos($response->headers->get('X-Symfony-Cache'), 'fresh')) { - $state = 'HIT'; - } else { - $state = 'UNDETERMINED'; - } - $response->headers->set('X-Cache', $state); + $this->addSubscriber(new CustomTtlListener()); + $this->addSubscriber(new PurgeSubscriber(['purge_method' => 'NOTIFY'])); + $this->addSubscriber(new RefreshSubscriber()); + $this->addSubscriber(new UserContextSubscriber()); + if (isset($options['debug']) && $options['debug']) { + $this->addSubscriber(new DebugListener()); } + } - return $response; + /** + * Made public to allow event subscribers to do refresh operations. + * + * {@inheritDoc} + */ + public function fetch(Request $request, $catch = false) + { + return parent::fetch($request, $catch); } } diff --git a/tests/Functional/Fixtures/web/symfony.php b/tests/Functional/Fixtures/web/symfony.php index 7add920b..b9c440b4 100644 --- a/tests/Functional/Fixtures/web/symfony.php +++ b/tests/Functional/Fixtures/web/symfony.php @@ -1,8 +1,5 @@ getCacheDir()), null, ['debug' => true]); -$kernel->addSubscriber(new PurgeSubscriber(['purge_method' => 'NOTIFY'])); -$kernel->addSubscriber(new RefreshSubscriber()); -$kernel->addSubscriber(new UserContextSubscriber()); $request = Request::createFromGlobals(); $response = $kernel->handle($request); $response->send(); diff --git a/tests/Unit/SymfonyCache/DebugListenerTest.php b/tests/Unit/SymfonyCache/DebugListenerTest.php new file mode 100644 index 00000000..a5cbb855 --- /dev/null +++ b/tests/Unit/SymfonyCache/DebugListenerTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\Tests\Unit\SymfonyCache; + +use FOS\HttpCache\SymfonyCache\CacheEvent; +use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; +use FOS\HttpCache\SymfonyCache\CustomTtlListener; +use FOS\HttpCache\SymfonyCache\DebugListener; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpCache\HttpCache; + +class DebugListenerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var CacheInvalidationInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $kernel; + + public function setUp() + { + $this->kernel = $this + ->getMockBuilder('FOS\HttpCache\SymfonyCache\CacheInvalidationInterface') + ->disableOriginalConstructor() + ->getMock() + ; + } + + public function testDebugHit() + { + $debugListener = new DebugListener(); + $request = Request::create('http://example.com/foo', 'GET'); + $response = new Response('', 200, array( + 'X-Symfony-Cache' => '... fresh ...', + )); + $event = new CacheEvent($this->kernel, $request, $response); + + $debugListener->handleDebug($event); + $response = $event->getResponse(); + + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertSame('HIT', $response->headers->get('X-Cache')); + } + + public function testDebugMiss() + { + $debugListener = new DebugListener(); + $request = Request::create('http://example.com/foo', 'GET'); + $response = new Response('', 200, array( + 'X-Symfony-Cache' => '... miss ...', + )); + $event = new CacheEvent($this->kernel, $request, $response); + + $debugListener->handleDebug($event); + $response = $event->getResponse(); + + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertSame('MISS', $response->headers->get('X-Cache')); + } + + public function testDebugUndefined() + { + $debugListener = new DebugListener(); + $request = Request::create('http://example.com/foo', 'GET'); + $response = new Response('', 200, array( + 'X-Symfony-Cache' => '... foobar ...', + )); + $event = new CacheEvent($this->kernel, $request, $response); + + $debugListener->handleDebug($event); + $response = $event->getResponse(); + + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertSame('UNDETERMINED', $response->headers->get('X-Cache')); + } + + public function testNoHeader() + { + $debugListener = new DebugListener(); + $request = Request::create('http://example.com/foo', 'GET'); + $response = new Response('', 200); + $event = new CacheEvent($this->kernel, $request, $response); + + $debugListener->handleDebug($event); + $response = $event->getResponse(); + + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertFalse($response->headers->has('X-Cache')); + } +} diff --git a/tests/Unit/SymfonyCache/EventDispatchingHttpCacheTest.php b/tests/Unit/SymfonyCache/EventDispatchingHttpCacheTest.php index 6a628507..aafe1477 100644 --- a/tests/Unit/SymfonyCache/EventDispatchingHttpCacheTest.php +++ b/tests/Unit/SymfonyCache/EventDispatchingHttpCacheTest.php @@ -11,179 +11,39 @@ namespace FOS\HttpCache\Tests\Unit\SymfonyCache; +use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache; use FOS\HttpCache\SymfonyCache\CacheEvent; use FOS\HttpCache\SymfonyCache\Events; +use FOS\HttpCache\Test\EventDispatchingHttpCacheTestCase; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpCache\HttpCache; use Symfony\Component\HttpKernel\HttpKernelInterface; -class EventDispatchingHttpCacheTest extends \PHPUnit_Framework_TestCase +/** + * This test ensures that the EventDispatchingHttpCache trait is correctly used. + */ +class EventDispatchingHttpCacheTest extends EventDispatchingHttpCacheTestCase { - /** - * @return EventDispatchingHttpCache|\PHPUnit_Framework_MockObject_MockObject - */ - protected function getHttpCachePartialMock(array $mockedMethods = null) + protected function getCacheClass() { - $mock = $this - ->getMockBuilder('\FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache') - ->setMethods( $mockedMethods ) - ->disableOriginalConstructor() - ->getMock() - ; - - // Force setting options property since we can't use original constructor. - $options = [ - 'debug' => false, - 'default_ttl' => 0, - 'private_headers' => [ 'Authorization', 'Cookie' ], - 'allow_reload' => false, - 'allow_revalidate' => false, - 'stale_while_revalidate' => 2, - 'stale_if_error' => 60, - ]; - - $refHttpCache = new \ReflectionClass('Symfony\Component\HttpKernel\HttpCache\HttpCache'); - // Workaround for Symfony 2.3 where $options property is not defined. - if (!$refHttpCache->hasProperty('options')) { - $mock->options = $options; - } else { - $refOptions = $refHttpCache->getProperty('options'); - $refOptions->setAccessible(true); - $refOptions->setValue($mock, $options ); - } - - return $mock; - } - - public function testCalledHandle() - { - $catch = true; - $request = Request::create('/foo', 'GET'); - $response = new Response(); - - $httpCache = $this->getHttpCachePartialMock(['lookup']); - $subscriber = new TestSubscriber($this, $httpCache, $request); - $httpCache->addSubscriber($subscriber); - $httpCache - ->expects($this->any()) - ->method('lookup') - ->with($request) - ->will($this->returnValue($response)) - ; - - $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); - $this->assertEquals(1, $subscriber->handleHits); - } - - public function testAbortHandle() - { - $catch = true; - $request = Request::create('/foo', 'GET'); - $response = new Response(); - - $httpCache = $this->getHttpCachePartialMock(['lookup']); - $subscriber = new TestSubscriber($this, $httpCache, $request); - $subscriber->handleResponse = $response; - $httpCache->addSubscriber($subscriber); - $httpCache - ->expects($this->never()) - ->method('lookup') - ; - - $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); - $this->assertEquals(1, $subscriber->handleHits); - } - - public function testCalledInvalidate() - { - $catch = true; - $request = Request::create('/foo', 'GET'); - $response = new Response('', 500); - - $httpCache = $this->getHttpCachePartialMock(['pass']); - $subscriber = new TestSubscriber($this, $httpCache, $request); - $httpCache->addSubscriber($subscriber); - $httpCache - ->expects($this->any()) - ->method('pass') - ->with($request) - ->will($this->returnValue($response)) - ; - $refHttpCache = new \ReflectionObject($httpCache); - $method = $refHttpCache->getMethod('invalidate'); - $method->setAccessible(true); - - $this->assertSame($response, $method->invokeArgs($httpCache, [$request, $catch])); - $this->assertEquals(1, $subscriber->invalidateHits); - } - - public function testAbortInvalidate() - { - $catch = true; - $request = Request::create('/foo', 'GET'); - $response = new Response('', 400); - - $httpCache = $this->getHttpCachePartialMock(['pass']); - $subscriber = new TestSubscriber($this, $httpCache, $request); - $subscriber->invalidateResponse = $response; - $httpCache->addSubscriber($subscriber); - $httpCache - ->expects($this->never()) - ->method('pass') - ; - $refHttpCache = new \ReflectionObject($httpCache); - $method = $refHttpCache->getMethod('invalidate'); - $method->setAccessible(true); - - $this->assertSame($response, $method->invokeArgs($httpCache, [$request, $catch])); - $this->assertEquals(1, $subscriber->invalidateHits); + return '\FOS\HttpCache\Tests\Unit\SymfonyCache\AppCache'; } } -class TestSubscriber implements EventSubscriberInterface +class AppCache extends HttpCache implements CacheInvalidationInterface { - public $handleHits = 0; - public $invalidateHits = 0; - public $handleResponse; - public $invalidateResponse; - private $test; - private $kernel; - private $request; - - public function __construct($test, $kernel, $request) - { - $this->test = $test; - $this->kernel = $kernel; - $this->request = $request; - } + use EventDispatchingHttpCache; - public static function getSubscribedEvents() - { - return [ - Events::PRE_HANDLE => 'preHandle', - Events::PRE_INVALIDATE => 'preInvalidate', - ]; - } - - public function preHandle(CacheEvent $event) - { - $this->test->assertSame($this->kernel, $event->getKernel()); - $this->test->assertSame($this->request, $event->getRequest()); - if ($this->handleResponse) { - $event->setResponse($this->handleResponse); - } - $this->handleHits++; - } - - public function preInvalidate(CacheEvent $event) + /** + * Made public to allow event subscribers to do refresh operations. + * + * {@inheritDoc} + */ + public function fetch(Request $request, $catch = false) { - $this->test->assertSame($this->kernel, $event->getKernel()); - $this->test->assertSame($this->request, $event->getRequest()); - if ($this->invalidateResponse) { - $event->setResponse($this->invalidateResponse); - } - $this->invalidateHits++; + parent::fetch($request, $catch); } } diff --git a/tests/Unit/SymfonyCache/PurgeSubscriberTest.php b/tests/Unit/SymfonyCache/PurgeSubscriberTest.php index 134e3545..7c0ec321 100644 --- a/tests/Unit/SymfonyCache/PurgeSubscriberTest.php +++ b/tests/Unit/SymfonyCache/PurgeSubscriberTest.php @@ -12,15 +12,15 @@ namespace FOS\HttpCache\Tests\Unit\SymfonyCache; use FOS\HttpCache\SymfonyCache\CacheEvent; +use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; use FOS\HttpCache\SymfonyCache\PurgeSubscriber; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestMatcher; -use Symfony\Component\HttpKernel\HttpCache\HttpCache; class PurgeSubscriberTest extends \PHPUnit_Framework_TestCase { /** - * @var HttpCache|\PHPUnit_Framework_MockObject_MockObject + * @var CacheInvalidationInterface|\PHPUnit_Framework_MockObject_MockObject */ private $kernel; diff --git a/tests/Unit/SymfonyCache/RefreshSubscriberTest.php b/tests/Unit/SymfonyCache/RefreshSubscriberTest.php index 43a71611..6f2cddcf 100644 --- a/tests/Unit/SymfonyCache/RefreshSubscriberTest.php +++ b/tests/Unit/SymfonyCache/RefreshSubscriberTest.php @@ -12,16 +12,16 @@ namespace FOS\HttpCache\Tests\Unit\SymfonyCache; use FOS\HttpCache\SymfonyCache\CacheEvent; +use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; use FOS\HttpCache\SymfonyCache\RefreshSubscriber; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestMatcher; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\HttpCache\HttpCache; class RefreshSubscriberTest extends \PHPUnit_Framework_TestCase { /** - * @var HttpCache|\PHPUnit_Framework_MockObject_MockObject + * @var CacheInvalidationInterface|\PHPUnit_Framework_MockObject_MockObject */ private $kernel; diff --git a/tests/Unit/SymfonyCache/UserContextSubscriberTest.php b/tests/Unit/SymfonyCache/UserContextSubscriberTest.php index d966210f..39f697d0 100644 --- a/tests/Unit/SymfonyCache/UserContextSubscriberTest.php +++ b/tests/Unit/SymfonyCache/UserContextSubscriberTest.php @@ -12,15 +12,15 @@ namespace FOS\HttpCache\Tests\Unit\SymfonyCache; use FOS\HttpCache\SymfonyCache\CacheEvent; +use FOS\HttpCache\SymfonyCache\CacheInvalidationInterface; use FOS\HttpCache\SymfonyCache\UserContextSubscriber; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\HttpCache\HttpCache; class UserContextSubscriberTest extends \PHPUnit_Framework_TestCase { /** - * @var HttpCache|\PHPUnit_Framework_MockObject_MockObject + * @var CacheInvalidationInterface|\PHPUnit_Framework_MockObject_MockObject */ private $kernel; diff --git a/tests/Unit/Test/PHPUnit/IsCacheHitConstraintTest.php b/tests/Unit/Test/PHPUnit/IsCacheHitConstraintTest.php index 6c1ff72e..e393ce75 100644 --- a/tests/Unit/Test/PHPUnit/IsCacheHitConstraintTest.php +++ b/tests/Unit/Test/PHPUnit/IsCacheHitConstraintTest.php @@ -47,8 +47,8 @@ public function testMatches() public function testMatchesThrowsExceptionIfHeaderIsMissing() { $response = $this->getResponseMock() - ->shouldReceive('hasHeader')->with('cache-header')->once() - ->andReturn(false) + ->shouldReceive('hasHeader')->with('cache-header')->once()->andReturn(false) + ->shouldReceive('getStatusCode')->andReturn(200) ->getMock(); $this->constraint->evaluate($response);