diff --git a/src/Datasource/DatasourceInterface.php b/src/Datasource/DatasourceInterface.php index 5cd4ed1a..4845dfb0 100644 --- a/src/Datasource/DatasourceInterface.php +++ b/src/Datasource/DatasourceInterface.php @@ -137,9 +137,28 @@ interface DatasourceInterface extends IndexPluginInterface { * * @return bool * TRUE if access is granted, FALSE otherwise. + * + * @deprecated in search_api:8.x-1.13 and will be removed from + * search_api:9.x-1.0. Use getItemAccessResult() instead. + * + * @see https://www.drupal.org/node/3051902 */ public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL); + /** + * Checks whether a user has permission to view the given item. + * + * @param \Drupal\Core\TypedData\ComplexDataInterface $item + * An item of this datasource's type. + * @param \Drupal\Core\Session\AccountInterface|null $account + * (optional) The user session for which to check access, or NULL to check + * access for the current user. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL); + /** * Returns the available view modes for this datasource. * @@ -238,4 +257,15 @@ interface DatasourceInterface extends IndexPluginInterface { */ public function getFieldDependencies(array $fields); + /** + * Returns the cacheability metadata for the given item. + * + * @param \Drupal\Core\TypedData\ComplexDataInterface $item + * The item of this datasource's type for which to return the metadata. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheability metadata. + */ + public function getItemCacheableMetadata(ComplexDataInterface $item); + } diff --git a/src/Datasource/DatasourcePluginBase.php b/src/Datasource/DatasourcePluginBase.php index e6cfea03..bb43310d 100644 --- a/src/Datasource/DatasourcePluginBase.php +++ b/src/Datasource/DatasourcePluginBase.php @@ -2,6 +2,8 @@ namespace Drupal\search_api\Datasource; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Language\Language; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComplexDataInterface; @@ -97,7 +99,15 @@ abstract class DatasourcePluginBase extends IndexPluginBase implements Datasourc * {@inheritdoc} */ public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL) { - return TRUE; + @trigger_error('\Drupal\search_api\Datasource\DatasourceInterface::checkItemAccess() is deprecated in search_api:8.x-1.13. It will be removed from search_api:9.x-1.0. Use getItemAccessResult() instead. See https://www.drupal.org/node/3051902', E_USER_DEPRECATED); + return $this->getItemAccessResult($item, $account)->isAllowed(); + } + + /** + * {@inheritdoc} + */ + public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL) { + return AccessResult::allowed(); } /** @@ -155,4 +165,11 @@ abstract class DatasourcePluginBase extends IndexPluginBase implements Datasourc return []; } + /** + * {@inheritdoc} + */ + public function getItemCacheableMetadata(ComplexDataInterface $item) { + return new CacheableMetadata(); + } + } diff --git a/src/Item/Item.php b/src/Item/Item.php index d120b6f9..abb37062 100644 --- a/src/Item/Item.php +++ b/src/Item/Item.php @@ -2,6 +2,8 @@ namespace Drupal\search_api\Item; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComplexDataInterface; use Drupal\search_api\Datasource\DatasourceInterface; @@ -406,13 +408,43 @@ class Item implements \IteratorAggregate, ItemInterface { * {@inheritdoc} */ public function checkAccess(AccountInterface $account = NULL) { + @trigger_error('\Drupal\search_api\Item\ItemInterface::checkAccess() is deprecated in search_api:8.x-1.13. It will be removed from search_api:9.x-1.0. Use getAccessResult() instead. See https://www.drupal.org/node/3051902', E_USER_DEPRECATED); + return $this->getAccessResult($account)->isAllowed(); + } + + /** + * {@inheritdoc} + */ + public function getAccessResult(AccountInterface $account = NULL) { + // @fixme Statically cache this by account. try { return $this->getDatasource() - ->checkItemAccess($this->getOriginalObject(), $account); + ->getItemAccessResult($this->getOriginalObject(), $account); } catch (SearchApiException $e) { - return FALSE; + return AccessResult::neutral('Item could not be loaded, so cannot check access'); + } + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata(AccountInterface $account = NULL) { + // @fixme Statically cache this by account. + $cacheability = new CacheableMetadata(); + $access = $this->getAccessResult($account); + $cacheability->addCacheableDependency($access); + if ($access->isAllowed()) { + try { + $item_cacheability = $this->getDatasource() + ->getItemCacheableMetadata($this->getOriginalObject()); + $cacheability->addCacheableDependency($item_cacheability); + } + catch (SearchApiException $e) { + // Ignore here. + } } + return $cacheability; } /** diff --git a/src/Item/ItemInterface.php b/src/Item/ItemInterface.php index a3fdbb6f..626af487 100644 --- a/src/Item/ItemInterface.php +++ b/src/Item/ItemInterface.php @@ -295,7 +295,35 @@ interface ItemInterface extends \Traversable { * * @return bool * TRUE if access is granted, FALSE otherwise. + * + * @deprecated in search_api:8.x-1.13 and will be removed from + * search_api:9.x-1.0. Use getAccessResult() instead. + * + * @see https://www.drupal.org/node/3051902 */ public function checkAccess(AccountInterface $account = NULL); + /** + * Checks whether a user has permission to view this item. + * + * @param \Drupal\Core\Session\AccountInterface|null $account + * (optional) The user for which to check access, or NULL to check access + * for the current user. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function getAccessResult(AccountInterface $account = NULL); + + /** + * Returns cacheability metadata. + * + * @param \Drupal\Core\Session\AccountInterface|null $account + * The user for which to return the metadata. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheability metadata. + */ + public function getCacheableMetadata(AccountInterface $account = NULL); + } diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php index 6224ce31..885585e8 100644 --- a/src/Plugin/search_api/datasource/ContentEntity.php +++ b/src/Plugin/search_api/datasource/ContentEntity.php @@ -2,6 +2,8 @@ namespace Drupal\search_api\Plugin\search_api\datasource; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; @@ -608,13 +610,14 @@ class ContentEntity extends DatasourcePluginBase implements EntityDatasourceInte /** * {@inheritdoc} */ - public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL) { - if ($entity = $this->getEntity($item)) { + public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL) { + $entity = $this->getEntity($item); + if ($entity) { return $this->getEntityTypeManager() ->getAccessControlHandler($this->getEntityTypeId()) - ->access($entity, 'view', $account); + ->access($entity, 'view', $account, TRUE); } - return FALSE; + return AccessResult::neutral('Item is not an entity, so cannot check access'); } /** @@ -1043,4 +1046,18 @@ class ContentEntity extends DatasourcePluginBase implements EntityDatasourceInte return $valid_ids; } + /** + * {@inheritdoc} + */ + public function getItemCacheableMetadata(ComplexDataInterface $item) { + $cacheability = parent::getItemCacheableMetadata($item); + + $entity = $this->getEntity($item); + if ($entity) { + $cacheability->addCacheableDependency($entity); + } + + return $cacheability; + } + } diff --git a/src/Plugin/views/cache/SearchApiCachePluginTrait.php b/src/Plugin/views/cache/SearchApiCachePluginTrait.php index 9f6e7100..df175bb1 100644 --- a/src/Plugin/views/cache/SearchApiCachePluginTrait.php +++ b/src/Plugin/views/cache/SearchApiCachePluginTrait.php @@ -173,8 +173,8 @@ trait SearchApiCachePluginTrait { */ public function generateResultsKey() { if (!isset($this->resultsKey)) { - $query = $this->getQuery()->getSearchApiQuery(); - $query->preExecute(); + // @todo Why is this here? Is this needed? + $this->getQuery()->getSearchApiQuery()->preExecute(); $view = $this->getView(); $build_info = $view->build_info; diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php index f77a15aa..66513f20 100644 --- a/src/Plugin/views/query/SearchApiQuery.php +++ b/src/Plugin/views/query/SearchApiQuery.php @@ -3,6 +3,8 @@ namespace Drupal\search_api\Plugin\views\query; use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Database\Query\ConditionInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -119,6 +121,33 @@ class SearchApiQuery extends QueryPluginBase { */ protected $messenger; + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The cacheability metadata of the result. + * + * Set in ::addResults, so only available after ::execute. + * + * Used in the ::getCache*() methods. + * + * @var \Drupal\Core\Cache\RefinableCacheableDependencyInterface + */ + protected $cacheableMetadata; + + /** + * {@inheritdoc} + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + $this->cacheableMetadata = new CacheableMetadata(); + } + /** * {@inheritdoc} */ @@ -129,6 +158,7 @@ class SearchApiQuery extends QueryPluginBase { $plugin->setModuleHandler($container->get('module_handler')); $plugin->setMessenger($container->get('messenger')); $plugin->setLogger($container->get('logger.channel.search_api')); + $plugin->setEntityTypeManager($container->get('entity_type.manager')); return $plugin; } @@ -241,6 +271,29 @@ class SearchApiQuery extends QueryPluginBase { return $this; } + /** + * Returns the entity type manager. + * + * @return \Drupal\Core\Entity\EntityTypeManagerInterface + * The entity type manager. + */ + public function getEntityTypeManager() { + return $this->entityTypeManager; + } + + /** + * Sets the entity type manager. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * + * @return $this + */ + public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + return $this; + } + /** * {@inheritdoc} */ @@ -631,13 +684,13 @@ class SearchApiQuery extends QueryPluginBase { $count = 0; // First, unless disabled, check access for all entities in the results. + $account = $this->getAccessAccount(); if (!$this->options['skip_access']) { - $account = $this->getAccessAccount(); // If search items are not loaded already, pre-load them now in bulk to // avoid them being individually loaded inside checkAccess(). $result_set->preLoadResultItems(); foreach ($results as $item_id => $result) { - if (!$result->checkAccess($account)) { + if (!$result->getAccessResult($account)->isAllowed()) { unset($results[$item_id]); } } @@ -685,6 +738,30 @@ class SearchApiQuery extends QueryPluginBase { $view->result[] = new ResultRow($values); } + + // Retrieve the cacheability metadata from the result set. + $this->cacheableMetadata = $result_set->getCacheableMetadata($account); + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + $contexts = []; + + foreach ($this->getIndex()->getEntityTypes() as $entity_type_id) { + $entity_type_definition = $this->entityTypeManager->getDefinition($entity_type_id); + $contexts = Cache::mergeContexts($entity_type_definition->getListCacheContexts(), $contexts); + } + + return $contexts; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return $this->getIndex()->getCacheTagsToInvalidate(); } /** diff --git a/src/Query/ResultSet.php b/src/Query/ResultSet.php index 781a9dfe..86992af4 100644 --- a/src/Query/ResultSet.php +++ b/src/Query/ResultSet.php @@ -2,6 +2,8 @@ namespace Drupal\search_api\Query; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Session\AccountInterface; use Drupal\search_api\Item\ItemInterface; use Drupal\search_api\SearchApiException; @@ -228,6 +230,18 @@ class ResultSet implements \IteratorAggregate, ResultSetInterface { return new \ArrayIterator($this->resultItems); } + /** + * {@inheritdoc} + */ + public function getCacheableMetadata(AccountInterface $account = NULL) { + $cacheability = new CacheableMetadata(); + foreach ($this->getResultItems() as $item) { + $other_cacheability = $item->getCacheableMetadata($account); + $cacheability = $cacheability->merge($other_cacheability); + } + return $cacheability; + } + /** * Implements the magic __toString() method to simplify debugging. */ diff --git a/src/Query/ResultSetInterface.php b/src/Query/ResultSetInterface.php index a32a46ee..1b2e1f34 100644 --- a/src/Query/ResultSetInterface.php +++ b/src/Query/ResultSetInterface.php @@ -2,6 +2,7 @@ namespace Drupal\search_api\Query; +use Drupal\Core\Session\AccountInterface; use Drupal\search_api\Item\ItemInterface; /** @@ -197,4 +198,15 @@ interface ResultSetInterface extends \Traversable { */ public function getCloneForQuery(QueryInterface $query); + /** + * Returns cacheability metadata. + * + * @param \Drupal\Core\Session\AccountInterface|null $account + * The user account for which to return the metadata. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheability metadata. + */ + public function getCacheableMetadata(AccountInterface $account = NULL); + } diff --git a/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml b/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml new file mode 100644 index 00000000..4b3488e9 --- /dev/null +++ b/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml @@ -0,0 +1,48 @@ +id: test_node_index +name: 'Test node index' +description: 'An index of node entities used for testing' +read_only: false +field_settings: + status: + label: Published + datasource_id: 'entity:node' + property_path: status + type: boolean + dependencies: + module: + - node + title: + label: Title + datasource_id: 'entity:node' + property_path: title + type: string + dependencies: + module: + - node +processor_settings: + add_url: { } + aggregated_field: { } + rendered_item: { } +options: + cron_limit: -1 + index_directly: true +datasource_settings: + 'entity:node': + bundles: + default: true + selected: { } + languages: + default: true + selected: { } +tracker_settings: + default: + indexing_order: fifo +server: database_search_server +status: true +langcode: en +dependencies: + config: + - search_api.server.database_search_server + module: + - node + - search_api diff --git a/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml b/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml new file mode 100644 index 00000000..0e79fda4 --- /dev/null +++ b/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml @@ -0,0 +1,13 @@ +id: database_search_server +name: 'Database search server' +description: 'A server used for testing' +backend: search_api_db +backend_config: + database: 'default:default' + min_chars: 3 + matching: words +status: true +langcode: en +dependencies: + module: + - search_api_db diff --git a/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml b/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml new file mode 100644 index 00000000..6f381fab --- /dev/null +++ b/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml @@ -0,0 +1,173 @@ +base_field: search_api_id +base_table: search_api_index_test_node_index +core: 8.x +description: '' +status: true +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: none + options: { } + cache: + type: tag + options: { } + query: + type: search_api_query + options: + bypass_access: false + skip_access: false + preserve_facet_query_args: false + exposed_form: + type: basic + options: + submit_button: Search + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: full + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 20, 40, 60' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: default + row: + type: fields + fields: + title: + id: title + table: search_api_index_test_node_index + field: title + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: string + settings: + link_to_entity: false + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + field_rendering: true + fallback_handler: search_api + fallback_options: + link_to_item: false + use_highlighting: false + multi_type: separator + multi_separator: ', ' + plugin_id: search_api_field + filters: { } + sorts: { } + title: 'Search API test node view' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - user.is_super_user + - 'user.node_grants:view' + tags: + - 'config:search_api.index.test_node_index' + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: search-api-test-node-view + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + tags: + - 'config:search_api.index.test_node_index' +label: 'Search API test node view' +module: views +id: search_api_test_node_view +tag: '' +langcode: en +dependencies: + config: + - search_api.index.test_node_index + module: + - search_api diff --git a/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml b/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml new file mode 100644 index 00000000..10cdc56f --- /dev/null +++ b/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml @@ -0,0 +1,8 @@ +type: module +name: 'Search API node indexing test' +description: 'Test module for testing indexing of nodes in Search API.' +package: 'Search API' +dependencies: + - search_api:search_api_db +core: 8.x +hidden: true diff --git a/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php new file mode 100644 index 00000000..8041c92d --- /dev/null +++ b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php @@ -0,0 +1,267 @@ +getParameter('renderer.config'); + $renderer_config['required_cache_contexts'] = []; + $container->setParameter('renderer.config', $renderer_config); + } + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installSchema('node', ['node_access']); + $this->installSchema('search_api', ['search_api_item']); + $this->installSchema('system', ['sequences']); + + $this->installEntitySchema('node'); + $this->installEntitySchema('search_api_task'); + $this->installEntitySchema('user'); + + $this->installConfig([ + 'node', + 'search_api', + 'search_api_test_node_indexing', + 'search_api_test_views', + ]); + + $this->entityTypeManager = $this->container->get('entity_type.manager'); + $this->viewExecutableFactory = $this->container->get('views.executable'); + $this->renderer = $this->container->get('renderer'); + $this->renderCache = $this->container->get('render_cache'); + + + // Use the test search index from the search_api_test_db module. + $this->index = Index::load('test_node_index'); + + // Create some demo content and index it. + foreach (['Cheery' => TRUE, 'Carrot' => TRUE, 'Detritus' => FALSE] as $title => $status) { + $this->createNode($title, $status); + } + $this->index->indexItems(); + + // Create two test users, one with permission to view unpublished entities, + // and one without. + $this->users['no-access'] = $this->createUser(); + $this->users['has-access'] = $this->createUser(['bypass node access']); + } + + public function testDisplayCacheInvalidation() { + // We are testing two variants of the view, one for users that have + // permission to view test entities, and one for users that do not. + // Initially both variants should be uncached. + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the user with the 'bypass node access' permission can see all + // 3 items. + $this->assertViewsResult('has-access', ['Cheery', 'Carrot', 'Detritus']); + + // The result should now be cached for the privileged user. + $this->assertNotCached('no-access'); + $this->assertCached('has-access'); + + // Check that the user without the 'bypass node access' permission can only + // see the published items. + $this->assertViewsResult('no-access', ['Cheery', 'Carrot']); + + // Both results should now be cached. + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Add another unpublished item. + $this->createNode('Angua', FALSE); + + // Our search index is not configured to automatically index items, so just + // creating a node should not invalidate the caches. + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Index the item, this should invalidate the caches. + $this->index->indexItems(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the user without the 'bypass node access' permission can still + // only see the published items. + $this->assertViewsResult('no-access', ['Cheery', 'Carrot']); + $this->assertCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the user with the 'bypass node access' permission can see all + // 4 items. + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Grant the permission to 'bypass node access' to the unprivileged user. + $privileged_role = reset($this->users['has-access']->getRoles()); + $this->users['no-access']->addRole($privileged_role); + $this->users['no-access']->save(); + + // The user should now be able to see all 4 items. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + } + + /** + * Checks that the view is cached for the given user. + * + * @param string $user_key + * The key of the user for which to perform the check. + */ + protected function assertCached($user_key) { + $this->doAssertCached('assertNotEmpty', $user_key); + } + + /** + * Checks that the view is not cached for the given user. + * + * @param string $user_key + * The key of the user for which to perform the check. + */ + protected function assertNotCached($user_key) { + $this->doAssertCached('assertEmpty', $user_key); + } + + /** + * Checks the cache status of the view for the given user. + * + * @param string $assert_method + * The method to use for asserting that the view is cached or not cached. + * @param int $user_key + * The key of the user for which to perform the check. + */ + protected function doAssertCached($assert_method, $user_key) { + $this->setCurrentUser($this->users[$user_key]); + + // Retrieve the view to render, and apply the required cache contexts that + // are also applied when RendererInterface::renderRoot() is executed. This + // ensures that we pass the same cache information to the render cache as is + // done when actually rendering the HTML root. + $view = $this->getView(); + $render_array = $view->buildRenderable(); + $render_array['#cache']['contexts'] = Cache::mergeContexts($render_array['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']); + + // Retrieve the cached data and perform the assertion. + $cached_data = $this->renderCache->get($render_array); + $this->$assert_method($cached_data); + } + + protected function setCurrentUser($user_key) { + throw new \Exception(__METHOD__); + } + + protected function createNode($title, $status) { + $values = [ + 'title' => $title, + 'status' => $status, + 'type' => 'page', + ]; + $this->nodes[$title] = Node::create($values); + $this->nodes[$title]->save(); + } + +}