You are here

VariationCacheTest.php in VariationCache 8

File

tests/src/Unit/VariationCacheTest.php
View source
<?php

namespace Drupal\Tests\variationcache\Unit;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\Cache\Context\ContextCacheKeys;
use Drupal\Core\Cache\MemoryBackend;
use Drupal\Tests\UnitTestCase;
use Drupal\variationcache\Cache\CacheRedirect;
use Drupal\variationcache\Cache\VariationCache;
use Prophecy\Argument;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * @coversDefaultClass \Drupal\variationcache\Cache\VariationCache
 * @group Cache
 */
class VariationCacheTest extends UnitTestCase {

  /**
   * The prophesized request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack|\Prophecy\Prophecy\ProphecyInterface
   */
  protected $requestStack;

  /**
   * The backend used by the variation cache.
   *
   * @var \Drupal\Core\Cache\MemoryBackend
   */
  protected $memoryBackend;

  /**
   * The prophesized cache contexts manager.
   *
   * @var \Drupal\Core\Cache\Context\CacheContextsManager|\Prophecy\Prophecy\ProphecyInterface
   */
  protected $cacheContextsManager;

  /**
   * The variation cache instance.
   *
   * @var \Drupal\variationcache\Cache\VariationCacheInterface
   */
  protected $variationCache;

  /**
   * The cache keys this test will store things under.
   *
   * @var string[]
   */
  protected $cacheKeys = [
    'your',
    'housing',
    'situation',
  ];

  /**
   * The cache ID for the cache keys, without taking contexts into account.
   *
   * @var string
   */
  protected $cacheIdBase = 'your:housing:situation';

  /**
   * The simulated current user's housing type.
   *
   * For use in tests with cache contexts.
   *
   * @var string
   */
  protected $housingType;

  /**
   * The cacheability for something that only varies per housing type.
   *
   * @var \Drupal\Core\Cache\CacheableMetadata
   */
  protected $housingTypeCacheability;

  /**
   * The simulated current user's garden type.
   *
   * For use in tests with cache contexts.
   *
   * @var string
   */
  protected $gardenType;

  /**
   * The cacheability for something that varies per housing and garden type.
   *
   * @var \Drupal\Core\Cache\CacheableMetadata
   */
  protected $gardenTypeCacheability;

  /**
   * The simulated current user's house's orientation.
   *
   * For use in tests with cache contexts.
   *
   * @var string
   */
  protected $houseOrientation;

  /**
   * The cacheability for varying per housing, garden and orientation.
   *
   * @var \Drupal\Core\Cache\CacheableMetadata
   */
  protected $houseOrientationCacheability;

  /**
   * The simulated current user's solar panel type.
   *
   * For use in tests with cache contexts.
   *
   * @var string
   */
  protected $solarType;

  /**
   * {@inheritdoc}
   */
  public function setUp() {
    parent::setUp();
    $this->requestStack = $this
      ->prophesize(RequestStack::class);
    $this->memoryBackend = new MemoryBackend();
    $this->cacheContextsManager = $this
      ->prophesize(CacheContextsManager::class);
    $housing_type =& $this->housingType;
    $garden_type =& $this->gardenType;
    $house_orientation =& $this->houseOrientation;
    $solar_type =& $this->solarType;
    $this->cacheContextsManager
      ->convertTokensToKeys(Argument::any())
      ->will(function ($args) use (&$housing_type, &$garden_type, &$house_orientation, &$solar_type) {
      $keys = [];
      foreach ($args[0] as $context_id) {
        switch ($context_id) {
          case 'house.type':
            $keys[] = "ht.{$housing_type}";
            break;
          case 'garden.type':
            $keys[] = "gt.{$garden_type}";
            break;
          case 'house.orientation':
            $keys[] = "ho.{$house_orientation}";
            break;
          case 'solar.type':
            $keys[] = "st.{$solar_type}";
            break;
          default:
            $keys[] = $context_id;
        }
      }
      return new ContextCacheKeys($keys);
    });
    $this->variationCache = new VariationCache($this->requestStack
      ->reveal(), $this->memoryBackend, $this->cacheContextsManager
      ->reveal());
    $this->housingTypeCacheability = (new CacheableMetadata())
      ->setCacheTags([
      'foo',
    ])
      ->setCacheContexts([
      'house.type',
    ]);
    $this->gardenTypeCacheability = (new CacheableMetadata())
      ->setCacheTags([
      'bar',
    ])
      ->setCacheContexts([
      'house.type',
      'garden.type',
    ]);
    $this->houseOrientationCacheability = (new CacheableMetadata())
      ->setCacheTags([
      'baz',
    ])
      ->setCacheContexts([
      'house.type',
      'garden.type',
      'house.orientation',
    ]);
  }

  /**
   * Tests a cache item that has no variations.
   *
   * @covers ::get
   * @covers ::set
   */
  public function testNoVariations() {
    $data = 'You have a nice house!';
    $cacheability = (new CacheableMetadata())
      ->setCacheTags([
      'bar',
      'foo',
    ]);
    $initial_cacheability = (new CacheableMetadata())
      ->setCacheTags([
      'foo',
    ]);
    $this
      ->setVariationCacheItem($data, $cacheability, $initial_cacheability);
    $this
      ->assertVariationCacheItem($data, $cacheability, $initial_cacheability);
  }

  /**
   * Tests a cache item that only ever varies by one context.
   *
   * @covers ::get
   * @covers ::set
   */
  public function testSingleVariation() {
    $cacheability = $this->housingTypeCacheability;
    $house_data = [
      'apartment' => 'You have a nice apartment',
      'house' => 'You have a nice house',
    ];
    foreach ($house_data as $housing_type => $data) {
      $this->housingType = $housing_type;
      $this
        ->assertVariationCacheMiss($cacheability);
      $this
        ->setVariationCacheItem($data, $cacheability, $cacheability);
      $this
        ->assertVariationCacheItem($data, $cacheability, $cacheability);
      $this
        ->assertCacheBackendItem("{$this->cacheIdBase}:ht.{$housing_type}", $data, $cacheability);
    }
  }

  /**
   * Tests a cache item that has nested variations.
   *
   * @covers ::get
   * @covers ::set
   */
  public function testNestedVariations() {

    // We are running this scenario in the best possible outcome: The redirects
    // are stored in expanding order, meaning the simplest one is stored first
    // and the nested ones are stored in subsequent ::set() calls. This means no
    // self-healing takes place where overly specific redirects are overwritten
    // with simpler ones.
    $possible_outcomes = [
      'apartment' => 'You have a nice apartment!',
      'house|no-garden' => 'You have a nice house!',
      'house|garden|east' => 'You have a nice house with an east-facing garden!',
      'house|garden|south' => 'You have a nice house with a south-facing garden!',
      'house|garden|west' => 'You have a nice house with a west-facing garden!',
      'house|garden|north' => 'You have a nice house with a north-facing garden!',
    ];
    foreach ($possible_outcomes as $cache_context_values => $data) {
      list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||');
      $cacheability = $this->housingTypeCacheability;
      if (!empty($this->houseOrientation)) {
        $cacheability = $this->houseOrientationCacheability;
      }
      elseif (!empty($this->gardenType)) {
        $cacheability = $this->gardenTypeCacheability;
      }
      $this
        ->assertVariationCacheMiss($this->housingTypeCacheability);
      $this
        ->setVariationCacheItem($data, $cacheability, $this->housingTypeCacheability);
      $this
        ->assertVariationCacheItem($data, $cacheability, $this->housingTypeCacheability);
      $cache_id = "{$this->cacheIdBase}:ht.{$this->housingType}";
      if (!empty($this->gardenType)) {
        $this
          ->assertCacheBackendItem($cache_id, new CacheRedirect($this->gardenTypeCacheability));
        $cache_id .= ":gt.{$this->gardenType}";
      }
      if (!empty($this->houseOrientation)) {
        $this
          ->assertCacheBackendItem($cache_id, new CacheRedirect($this->houseOrientationCacheability));
        $cache_id .= ":ho.{$this->houseOrientation}";
      }
      $this
        ->assertCacheBackendItem($cache_id, $data, $cacheability);
    }
  }

  /**
   * Tests a cache item that has nested variations that trigger self-healing.
   *
   * @covers ::get
   * @covers ::set
   *
   * @depends testNestedVariations
   */
  public function testNestedVariationsSelfHealing() {

    // This is the worst possible scenario: A very specific item was stored
    // first, followed by a less specific one. This means an overly specific
    // cache redirect was stored that needs to be dumbed down. After this
    // process, the first ::get() for the more specific item will fail as we
    // have effectively destroyed the path to said item. Setting an item of the
    // same specificity will restore the path for all items of said specificity.
    $cache_id = "{$this->cacheIdBase}:ht.house";
    $possible_outcomes = [
      'house|garden|east' => 'You have a nice house with an east-facing garden!',
      'house|garden|south' => 'You have a nice house with a south-facing garden!',
      'house|garden|west' => 'You have a nice house with a west-facing garden!',
      'house|garden|north' => 'You have a nice house with a north-facing garden!',
    ];
    foreach ($possible_outcomes as $cache_context_values => $data) {
      list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||');
      $this
        ->setVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability);
    }

    // Verify that the overly specific redirect is stored at the first possible
    // redirect location, i.e.: The base cache ID.
    $this
      ->assertCacheBackendItem($cache_id, new CacheRedirect($this->houseOrientationCacheability));

    // Store a simpler variation and verify that the first cache redirect is now
    // the one redirecting to the simplest known outcome.
    list($this->housingType, $this->gardenType, $this->houseOrientation) = [
      'house',
      'no-garden',
      NULL,
    ];
    $this
      ->setVariationCacheItem('You have a nice house', $this->gardenTypeCacheability, $this->housingTypeCacheability);
    $this
      ->assertCacheBackendItem($cache_id, new CacheRedirect($this->gardenTypeCacheability));

    // Verify that the previously set outcomes are all inaccessible now.
    foreach ($possible_outcomes as $cache_context_values => $data) {
      list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||');
      $this
        ->assertVariationCacheMiss($this->housingTypeCacheability);
    }

    // Set at least one more specific item in the cache again.
    $this
      ->setVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability);

    // Verify that the previously set outcomes are all accessible again.
    foreach ($possible_outcomes as $cache_context_values => $data) {
      list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||');
      $this
        ->assertVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability);
    }

    // Verify that the more specific cache redirect is now stored one step after
    // the less specific one.
    $this
      ->assertCacheBackendItem("{$cache_id}:gt.garden", new CacheRedirect($this->houseOrientationCacheability));
  }

  /**
   * Tests self-healing for a cache item that has split variations.
   *
   * @covers ::get
   * @covers ::set
   */
  public function testSplitVariationsSelfHealing() {

    // This is an edge case. Something varies by AB where some values of B
    // trigger the whole to vary by either C, D or nothing extra. But due to an
    // unfortunate series of requests, only ABC and ABD variations were cached.
    //
    // In this case, the cache should be smart enough to generate a redirect for
    // AB, followed by redirects for ABC and ABD.
    //
    // For the sake of this test, we'll vary by housing and orientation, but:
    // - Only vary by garden type for south-facing houses.
    // - Only vary by solar panel type for north-facing houses.
    $cache_id = "{$this->cacheIdBase}:ht.house";
    $this->housingType = 'house';
    $this->gardenType = 'garden';
    $this->solarType = 'solar';
    $initial_cacheability = (new CacheableMetadata())
      ->setCacheTags([
      'foo',
    ])
      ->setCacheContexts([
      'house.type',
    ]);
    $south_cacheability = (new CacheableMetadata())
      ->setCacheTags([
      'foo',
    ])
      ->setCacheContexts([
      'house.type',
      'house.orientation',
      'garden.type',
    ]);
    $north_cacheability = (new CacheableMetadata())
      ->setCacheTags([
      'foo',
    ])
      ->setCacheContexts([
      'house.type',
      'house.orientation',
      'solar.type',
    ]);
    $common_cacheability = (new CacheableMetadata())
      ->setCacheContexts([
      'house.type',
      'house.orientation',
    ]);

    // Set the first scenario.
    $this->houseOrientation = 'south';
    $this
      ->setVariationCacheItem('You have a south-facing house with a garden!', $south_cacheability, $initial_cacheability);

    // Verify that the overly specific redirect is stored at the first possible
    // redirect location, i.e.: The base cache ID.
    $this
      ->assertCacheBackendItem($cache_id, new CacheRedirect($south_cacheability));

    // Store a split variation, and verify that the common contexts are now used
    // for the first cache redirect and the actual contexts for the next step of
    // the redirect chain.
    $this->houseOrientation = 'north';
    $this
      ->setVariationCacheItem('You have a north-facing house with solar panels!', $north_cacheability, $initial_cacheability);
    $this
      ->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability));
    $this
      ->assertCacheBackendItem("{$cache_id}:ho.north", new CacheRedirect($north_cacheability));

    // Verify that the initially set scenario is inaccessible now.
    $this->houseOrientation = 'south';
    $this
      ->assertVariationCacheMiss($initial_cacheability);

    // Reset the initial scenario and verify that its redirects are accessible.
    $this
      ->setVariationCacheItem('You have a south-facing house with a garden!', $south_cacheability, $initial_cacheability);
    $this
      ->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability));
    $this
      ->assertCacheBackendItem("{$cache_id}:ho.south", new CacheRedirect($south_cacheability));

    // Double-check that the split scenario redirects are left untouched.
    $this->houseOrientation = 'north';
    $this
      ->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability));
    $this
      ->assertCacheBackendItem("{$cache_id}:ho.north", new CacheRedirect($north_cacheability));
  }

  /**
   * Tests exception for a cache item that has incompatible variations.
   *
   * @covers ::get
   * @covers ::set
   */
  public function testIncompatibleVariationsException() {

    // This should never happen. When someone first stores something in the
    // cache using context A and then tries to store something using context B,
    // something is wrong. There should always be at least one shared context at
    // the top level or else the cache cannot do its job.
    $this
      ->setExpectedException(\LogicException::class, "The complete set of cache contexts for a variation cache item must contain all of the initial cache contexts.");
    $this->housingType = 'house';
    $house_cacheability = (new CacheableMetadata())
      ->setCacheContexts([
      'house.type',
    ]);
    $this->gardenType = 'garden';
    $garden_cacheability = (new CacheableMetadata())
      ->setCacheContexts([
      'garden.type',
    ]);
    $this
      ->setVariationCacheItem('You have a nice garden!', $garden_cacheability, $garden_cacheability);
    $this
      ->setVariationCacheItem('You have a nice house!', $house_cacheability, $garden_cacheability);
  }

  /**
   * Stores an item in the variation cache.
   *
   * @param mixed $data
   *   The data that should be stored.
   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
   *   The cacheability that should be used.
   * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability
   *   The initial cacheability that should be used.
   */
  protected function setVariationCacheItem($data, CacheableMetadata $cacheability, CacheableMetadata $initial_cacheability) {
    $this->variationCache
      ->set($this->cacheKeys, $data, $cacheability, $initial_cacheability);
  }

  /**
   * Asserts that an item was properly stored in the variation cache.
   *
   * @param mixed $data
   *   The data that should have been stored.
   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
   *   The cacheability that should have been used.
   * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability
   *   The initial cacheability that should be used.
   */
  protected function assertVariationCacheItem($data, CacheableMetadata $cacheability, CacheableMetadata $initial_cacheability) {
    $cache_item = $this->variationCache
      ->get($this->cacheKeys, $initial_cacheability);
    $this
      ->assertNotFalse($cache_item, 'Variable data was stored and retrieved successfully.');
    $this
      ->assertEquals($data, $cache_item->data, 'Variable cache item contains the right data.');
    $this
      ->assertSame($cacheability
      ->getCacheTags(), $cache_item->tags, 'Variable cache item uses the right cache tags.');
  }

  /**
   * Asserts that an item could not be retrieved from the variation cache.
   *
   * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability
   *   The initial cacheability that should be used.
   */
  protected function assertVariationCacheMiss(CacheableMetadata $initial_cacheability) {
    $this
      ->assertFalse($this->variationCache
      ->get($this->cacheKeys, $initial_cacheability), 'Nothing could be retrieved for the active cache contexts.');
  }

  /**
   * Asserts that an item was properly stored in the cache backend.
   *
   * @param string $cid
   *   The cache ID that should have been used.
   * @param mixed $data
   *   The data that should have been stored.
   * @param \Drupal\Core\Cache\CacheableMetadata|null $cacheability
   *   (optional) The cacheability that should have been used. Does not apply
   *   when checking for cache redirects.
   */
  protected function assertCacheBackendItem($cid, $data, CacheableMetadata $cacheability = NULL) {
    $cache_backend_item = $this->memoryBackend
      ->get($cid);
    $this
      ->assertNotFalse($cache_backend_item, 'The data was stored and retrieved successfully.');
    $this
      ->assertEquals($data, $cache_backend_item->data, 'Cache item contains the right data.');
    if ($data instanceof CacheRedirect) {
      $this
        ->assertSame([], $cache_backend_item->tags, 'A cache redirect does not use cache tags.');
      $this
        ->assertSame(-1, $cache_backend_item->expire, 'A cache redirect is stored indefinitely.');
    }
    else {
      $this
        ->assertSame($cacheability
        ->getCacheTags(), $cache_backend_item->tags, 'Cache item uses the right cache tags.');
    }
  }

}

Classes

Namesort descending Description
VariationCacheTest @coversDefaultClass \Drupal\variationcache\Cache\VariationCache @group Cache