diff --git a/src/Datasource/DatasourceInterface.php b/src/Datasource/DatasourceInterface.php index 5cd4ed1a..edf02020 100644 --- a/src/Datasource/DatasourceInterface.php +++ b/src/Datasource/DatasourceInterface.php @@ -238,4 +238,15 @@ interface DatasourceInterface extends IndexPluginInterface { */ public function getFieldDependencies(array $fields); + /** + * The list cache contexts associated with this datasource. + * + * Allows lists that contain items from this data source to be varied as + * necessary, typically to ensure users of role A see other items listed than + * users of role B. + * + * @return string[] + */ + public function getListCacheContexts(); + } diff --git a/src/Datasource/DatasourcePluginBase.php b/src/Datasource/DatasourcePluginBase.php index e6cfea03..5cc1391f 100644 --- a/src/Datasource/DatasourcePluginBase.php +++ b/src/Datasource/DatasourcePluginBase.php @@ -155,4 +155,11 @@ abstract class DatasourcePluginBase extends IndexPluginBase implements Datasourc return []; } + /** + * {@inheritdoc} + */ + public function getListCacheContexts() { + return []; + } + } diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php index 69915d35..c7694791 100644 --- a/src/Plugin/search_api/datasource/ContentEntity.php +++ b/src/Plugin/search_api/datasource/ContentEntity.php @@ -3,6 +3,7 @@ namespace Drupal\search_api\Plugin\search_api\datasource; use Drupal\Component\Utility\Crypt; +use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\ContentEntityInterface; @@ -1169,4 +1170,12 @@ class ContentEntity extends DatasourcePluginBase implements EntityDatasourceInte return $valid_ids; } + /** + * {@inheritdoc} + */ + public function getListCacheContexts() { + $contexts = parent::getListCacheContexts(); + return Cache::mergeContexts($this->getEntityType()->getListCacheContexts(), $contexts); + } + } diff --git a/src/Plugin/views/cache/SearchApiTagCache.php b/src/Plugin/views/cache/SearchApiTagCache.php index 683aae2a..9527c82a 100644 --- a/src/Plugin/views/cache/SearchApiTagCache.php +++ b/src/Plugin/views/cache/SearchApiTagCache.php @@ -78,8 +78,14 @@ class SearchApiTagCache extends Tag { */ public function getCacheTags() { $tags = $this->view->storage->getCacheTags(); + // Add the list cache tag of the search index, so that the view will be + // invalidated whenever the index is updated. $tag = 'search_api_list:' . $this->getQuery()->getIndex()->id(); $tags = Cache::mergeTags([$tag], $tags); + // Also add the cache tags of the index itself, so that the view will be + // invalidated if the configuration of the index changes. + $index_tags = $this->getQuery()->getIndex()->getCacheTagsToInvalidate(); + $tags = Cache::mergeTags($index_tags, $tags); return $tags; } diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php index 41dd9760..09ea1297 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\CacheableDependencyInterface; use Drupal\Core\Database\Query\ConditionInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -688,6 +690,32 @@ class SearchApiQuery extends QueryPluginBase { } } + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + $query = $this->getSearchApiQuery(); + if ($query instanceof CacheableDependencyInterface) { + return $query->getCacheContexts(); + } + + return []; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + $tags = parent::getCacheTags(); + + $query = $this->getSearchApiQuery(); + if ($query instanceof CacheableDependencyInterface) { + $tags = Cache::mergeTags($query->getCacheTags(), $tags); + } + + return $tags; + } + /** * Retrieves the conditions placed on this query. * diff --git a/src/Query/Query.php b/src/Query/Query.php index e415cc2c..18f545de 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -2,6 +2,9 @@ namespace Drupal\search_api\Query; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\RefinableCacheableDependencyInterface; +use Drupal\Core\Cache\RefinableCacheableDependencyTrait; use Drupal\Core\DependencyInjection\DependencySerializationTrait; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -15,9 +18,10 @@ use Drupal\search_api\Utility\QueryHelperInterface; /** * Provides a standard implementation for a Search API query. */ -class Query implements QueryInterface { +class Query implements QueryInterface, RefinableCacheableDependencyInterface { use StringTranslationTrait; + use RefinableCacheableDependencyTrait; use DependencySerializationTrait { __sleep as traitSleep; __wakeup as traitWakeup; @@ -696,6 +700,33 @@ class Query implements QueryInterface { return $this->originalQuery ?: clone $this; } + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + $contexts = $this->cacheContexts; + + foreach ($this->getIndex()->getDatasources() as $datasource) { + $contexts = Cache::mergeContexts($datasource->getListCacheContexts(), $contexts); + } + + return $contexts; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + $tags = $this->cacheTags; + + // If the configuration of the search index changes we should invalidate the + // views that show results from this index. + $index_tags = $this->getIndex()->getCacheTagsToInvalidate(); + $tags = Cache::mergeTags($index_tags, $tags); + + return $tags; + } + /** * {@inheritdoc} */ diff --git a/tests/search_api_test/search_api_test.module b/tests/search_api_test/search_api_test.module index deeb41a7..eb14868a 100644 --- a/tests/search_api_test/search_api_test.module +++ b/tests/search_api_test/search_api_test.module @@ -14,7 +14,7 @@ use Drupal\node\NodeInterface; function search_api_test_node_grants(AccountInterface $account, $op) { $grants = []; - if (\Drupal::state()->get('search_api_test_add_node_access_grant', TRUE)) { + if (\Drupal::state()->get('search_api_test_add_node_access_grant', FALSE)) { $grants['search_api_test'] = [$account->id()]; } @@ -27,7 +27,7 @@ function search_api_test_node_grants(AccountInterface $account, $op) { function search_api_test_node_access_records(NodeInterface $node) { $grants = []; - if (\Drupal::state()->get('search_api_test_add_node_access_grant', TRUE)) { + if (\Drupal::state()->get('search_api_test_add_node_access_grant', FALSE)) { $grants[] = [ 'realm' => 'search_api_test', 'gid' => $node->getOwnerId(), 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..b2af7e1d --- /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: false +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..0ee6bce9 --- /dev/null +++ b/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml @@ -0,0 +1,107 @@ +langcode: en +status: true +dependencies: + config: + - search_api.index.test_node_index + module: + - search_api +id: search_api_test_node_view +label: 'Search API test node view' +module: views +description: '' +tag: '' +base_table: search_api_index_test_node_index +base_field: search_api_id +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: none + options: { } + cache: + type: search_api_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 + 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.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' 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/Processor/ContentAccessTest.php b/tests/src/Kernel/Processor/ContentAccessTest.php index acd9af7d..82d91fe5 100644 --- a/tests/src/Kernel/Processor/ContentAccessTest.php +++ b/tests/src/Kernel/Processor/ContentAccessTest.php @@ -48,6 +48,9 @@ class ContentAccessTest extends ProcessorTestBase { public function setUp($processor = NULL) { parent::setUp('content_access'); + // Activate our custom grant. + \Drupal::state()->set('search_api_test_add_node_access_grant', TRUE); + // Create a node type for testing. $type = NodeType::create(['type' => 'page', 'name' => 'page']); $type->save(); diff --git a/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php new file mode 100644 index 00000000..e24889da --- /dev/null +++ b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php @@ -0,0 +1,458 @@ +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'); + $this->cacheTagsInvalidator = $this->container->get('cache_tags.invalidator'); + $this->currentUser = $this->container->get('current_user'); + + DateFormat::create([ + 'id' => 'fallback', + 'label' => 'Fallback', + 'pattern' => 'Y-m-d', + ])->save(); + + // Use the test search index from the search_api_test_db module. + $this->index = Index::load('test_node_index'); + + // Create a test content type. + $this->contentType = NodeType::create([ + 'name' => 'Page', + 'type' => 'page', + ]); + $this->contentType->save(); + + // Create some test content and index it. + foreach (['Cheery' => TRUE, 'Carrot' => TRUE, 'Detritus' => FALSE] as $title => $status) { + $this->createNode($title, $status); + } + $this->index->indexItems(); + + // Create a dummy test user. This user will get UID 1 which is handled as + // the root user and can bypass all access restrictions. This is not used + // in the test. + $this->createUser(); + + // Create two test users, one with permission to view unpublished entities, + // and one without. + $this->users['no-access'] = $this->createUser(['access content']); + $this->users['has-access'] = $this->createUser(['access content', 'bypass node access']); + } + + /** + * Tests that a cached views display is invalidated at the right occasions. + */ + public function testDisplayCacheInvalidation() { + // We are testing two variants of the view, one for users that have + // permission to view unpublished 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 = $this->users['has-access']->getRoles()[1]; + $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'); + + // Edit one of the test content entities. This should not affect the cached + // view until the search index is updated. + $this->nodes['Cheery']->set('title', 'Cheery Littlebottom')->save(); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + $this->index->indexItems(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // The view should show the updated title when displayed, and the result + // should be cached. + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']); + $this->assertCached('has-access'); + + // Delete one of the test content entities. This takes effect immediately, + // there is no need to wait until the search index is updated. + // @see search_api_entity_delete() + $this->nodes['Carrot']->delete(); + $this->assertNotCached('has-access'); + + // The view should no longer include the deleted content now, and the result + // should be cached after the view has been displayed. + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertCached('has-access'); + + // Update the search index configuration so it will index items immediately + // when they are created or updated. + $this->index->setOption('index_directly', TRUE)->save(); + + // Changing the configuration of the index should invalidate all views that + // show its data. + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the expected results are still returned and are cacheable. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Change the configuration of the view. This should also invalidate all + // displays of the view. + $view = $this->getView(); + $view->setItemsPerPage(20); + $view->save(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the expected results are still returned and are cacheable. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Edit one of the test content entities. Because the search index is being + // updated immediately, the cached views should be cleared without having to + // perform a manual indexing step. + $this->nodes['Angua']->set('title', 'Angua von Überwald')->save(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the updated results are shown and are cacheable. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertViewsResult('has-access', ['Angua', 'Cheery', '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) { + // Ensure that any post request indexing is done. This is normally handled + // at the end of the request but since we are running a KernelTest we are + // not executing any requests and need to trigger this manually. + $this->triggerPostRequestIndexing(); + + // Set the user that will be used to check the cache status. + $this->setCurrentUser($user_key); + + // Retrieve the cached data and perform the assertion. + $view = $this->getView(); + $view->build(); + /** @var \Drupal\views\Plugin\views\cache\CachePluginBase $cache */ + $cache = $view->getDisplay()->getPlugin('cache'); + $cached_data = $cache->cacheGet('results'); + + $this->$assert_method($cached_data); + } + + /** + * Checks that the view for the given user contains the expected results. + * + * @param string $user_key + * The key of the user to check. + * @param array $node_keys + * The keys of the nodes that are expected to be present in the result set. + */ + protected function assertViewsResult($user_key, array $node_keys) { + // Clear the static caches of the cache tags invalidators. The invalidators + // will only invalidate cache tags once per request to improve performance. + // Unfortunately they cannot distinguish between an actual Drupal page + // request and a PHPUnit test that simulates visiting multiple pages. + // We are pretending that every time this method is called a new page has + // been requested, and the static caches are empty. + $this->cacheTagsInvalidator->resetChecksums(); + + $this->setCurrentUser($user_key); + + $render_array = $this->getRenderableView(); + $html = (string) $this->renderer->renderRoot($render_array); + + // Check that the titles of the expected results are present. + foreach ($node_keys as $node_key) { + $label = $this->nodes[$node_key]->label(); + $this->assertContains($label, $html); + } + + // Also check that none of the titles of the remaining search items are + // unexpectedly present. + $unexpected_keys = array_diff(array_keys($this->nodes), $node_keys); + foreach ($unexpected_keys as $unexpected_key) { + $label = $this->nodes[$unexpected_key]->label(); + $this->assertNotContains($label, $html); + } + } + + /** + * Sets the user with the given key as the currently active user. + * + * @param string $user_key + * The key of the user to set as currently active user. + */ + protected function setCurrentUser($user_key) { + $this->currentUser->setAccount($this->users[$user_key]); + } + + /** + * Returns the test view as a render array. + * + * @return array|null + * The render array, or NULL if the view cannot be rendered. + */ + protected function getRenderableView() { + $render_array = $this->getView()->buildRenderable(); + $render_array['#cache']['contexts'] = Cache::mergeContexts($render_array['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']); + + return $render_array; + } + + /** + * Returns the test view. + * + * @return \Drupal\views\ViewExecutable + * The view. + */ + protected function getView() { + /** @var \Drupal\views\ViewEntityInterface $view */ + $view = $this->entityTypeManager->getStorage('view')->load(self::TEST_VIEW_ID); + $executable = $this->viewExecutableFactory->get($view); + $executable->setDisplay(self::TEST_VIEW_DISPLAY_ID); + return $executable; + } + + /** + * Creates a node with the given title and publication status. + * + * @param string $title + * The title for the node. + * @param bool $status + * The publication status to set. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * Thrown if an error occurred during the saving of the node. + */ + protected function createNode($title, $status) { + $values = [ + 'title' => $title, + 'status' => $status, + 'type' => $this->contentType->id(), + ]; + $this->nodes[$title] = Node::create($values); + $this->nodes[$title]->save(); + } + +} diff --git a/tests/src/Kernel/Views/ViewsDisplayCachingTest.php b/tests/src/Kernel/Views/ViewsDisplayCachingTest.php index ce6d8505..9d669d6a 100644 --- a/tests/src/Kernel/Views/ViewsDisplayCachingTest.php +++ b/tests/src/Kernel/Views/ViewsDisplayCachingTest.php @@ -317,15 +317,16 @@ class ViewsDisplayCachingTest extends KernelTestBase { // Views. This is expected to disable caching. [ 'none', - // It is expected that only the configuration of the view itself is - // available as a cache tag. + // Cache tags for index and view config are included at the query level, + // so should still be present even when disabling caching. [ + 'config:search_api.index.database_search_index', 'config:views.view.search_api_test_cache', ], // No specific cache contexts are expected to be present. [], - // It is expected that the cache max-age is set to zero, effectively - // disabling the cache. + // The cache max-age should be returned as zero, effectively disabling + // the cache. 0, // It is expected that no results are cached. FALSE, @@ -337,9 +338,9 @@ class ViewsDisplayCachingTest extends KernelTestBase { [ 'tag', [ - // It is expected that the configuration of the view itself is - // available as a cache tag, so that the caches are invalidated if the - // view configuration changes. + // The cache should be invalidated when either index or view are + // modified. + 'config:search_api.index.database_search_index', 'config:views.view.search_api_test_cache', // The view shows an entity, so it should be invalidated when that // entity changes. @@ -362,9 +363,9 @@ class ViewsDisplayCachingTest extends KernelTestBase { [ 'time', [ - // It is expected that the configuration of the view itself is - // available as a cache tag, so that the caches are invalidated if the - // view configuration changes. No other tags should be available. + // The cache should be invalidated when either index or view are + // modified. + 'config:search_api.index.database_search_index', 'config:views.view.search_api_test_cache', ], // No specific cache contexts are expected to be present.