diff --git a/browsertest-convert.php b/browsertest-convert.php new file mode 100644 index 0000000..3e2d6be --- /dev/null +++ b/browsertest-convert.php @@ -0,0 +1,402 @@ +profile != 'standard') { + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + } + + $this->adminUser = $this->drupalCreateUser(array('access administration pages', 'administer news feeds', 'access news feeds', 'create article content')); + $this->drupalLogin($this->adminUser); + $this->drupalPlaceBlock('local_tasks_block'); + } + + /** + * Creates an aggregator feed. + * + * This method simulates the form submission on path aggregator/sources/add. + * + * @param string $feed_url + * (optional) If given, feed will be created with this URL, otherwise + * /rss.xml will be used. Defaults to NULL. + * @param array $edit + * Array with additional form fields. + * + * @return \Drupal\aggregator\FeedInterface + * Full feed object if possible. + * + * @see getFeedEditArray() + */ + public function createFeed($feed_url = NULL, array $edit = array()) { + $edit = $this->getFeedEditArray($feed_url, $edit); + $this->drupalPostForm('aggregator/sources/add', $edit, t('Save')); + $this->assertText(t('The feed @name has been added.', array('@name' => $edit['title[0][value]'])), format_string('The feed @name has been added.', array('@name' => $edit['title[0][value]']))); + + // Verify that the creation message contains a link to a feed. + $view_link = $this->xpath('//div[@class="messages"]//a[contains(@href, :href)]', array(':href' => 'aggregator/sources/')); + $this->assert(isset($view_link), 'The message area contains a link to a feed'); + + $fid = db_query("SELECT fid FROM {aggregator_feed} WHERE title = :title AND url = :url", array(':title' => $edit['title[0][value]'], ':url' => $edit['url[0][value]']))->fetchField(); + $this->assertTrue(!empty($fid), 'The feed found in database.'); + return Feed::load($fid); + } + + /** + * Deletes an aggregator feed. + * + * @param \Drupal\aggregator\FeedInterface $feed + * Feed object representing the feed. + */ + public function deleteFeed(FeedInterface $feed) { + $this->drupalPostForm('aggregator/sources/' . $feed->id() . '/delete', array(), t('Delete')); + $this->assertRaw(t('The feed %title has been deleted.', array('%title' => $feed->label())), 'Feed deleted successfully.'); + } + + /** + * Returns a randomly generated feed edit array. + * + * @param string $feed_url + * (optional) If given, feed will be created with this URL, otherwise + * /rss.xml will be used. Defaults to NULL. + * @param array $edit + * Array with additional form fields. + * + * @return array + * A feed array. + */ + public function getFeedEditArray($feed_url = NULL, array $edit = array()) { + $feed_name = $this->randomMachineName(10); + if (!$feed_url) { + $feed_url = \Drupal::url('view.frontpage.feed_1', array(), array( + 'query' => array('feed' => $feed_name), + 'absolute' => TRUE, + )); + } + $edit += array( + 'title[0][value]' => $feed_name, + 'url[0][value]' => $feed_url, + 'refresh' => '900', + ); + return $edit; + } + + /** + * Returns a randomly generated feed edit object. + * + * @param string $feed_url + * (optional) If given, feed will be created with this URL, otherwise + * /rss.xml will be used. Defaults to NULL. + * @param array $values + * (optional) Default values to initialize object properties with. + * + * @return \Drupal\aggregator\FeedInterface + * A feed object. + */ + public function getFeedEditObject($feed_url = NULL, array $values = array()) { + $feed_name = $this->randomMachineName(10); + if (!$feed_url) { + $feed_url = \Drupal::url('view.frontpage.feed_1', array( + 'query' => array('feed' => $feed_name), + 'absolute' => TRUE, + )); + } + $values += array( + 'title' => $feed_name, + 'url' => $feed_url, + 'refresh' => '900', + ); + return Feed::create($values); + } + + /** + * Returns the count of the randomly created feed array. + * + * @return int + * Number of feed items on default feed created by createFeed(). + */ + public function getDefaultFeedItemCount() { + // Our tests are based off of rss.xml, so let's find out how many elements should be related. + $feed_count = db_query_range('SELECT COUNT(DISTINCT nid) FROM {node_field_data} n WHERE n.promote = 1 AND n.status = 1', 0, $this->config('system.rss')->get('items.limit'))->fetchField(); + return $feed_count > 10 ? 10 : $feed_count; + } + + /** + * Updates the feed items. + * + * This method simulates a click to + * admin/config/services/aggregator/update/$fid. + * + * @param \Drupal\aggregator\FeedInterface $feed + * Feed object representing the feed. + * @param int|null $expected_count + * Expected number of feed items. If omitted no check will happen. + */ + public function updateFeedItems(FeedInterface $feed, $expected_count = NULL) { + // First, let's ensure we can get to the rss xml. + $this->drupalGet($feed->getUrl()); + $this->assertResponse(200, format_string(':url is reachable.', array(':url' => $feed->getUrl()))); + + // Attempt to access the update link directly without an access token. + $this->drupalGet('admin/config/services/aggregator/update/' . $feed->id()); + $this->assertResponse(403); + + // Refresh the feed (simulated link click). + $this->drupalGet('admin/config/services/aggregator'); + $this->clickLink('Update items'); + + // Ensure we have the right number of items. + $result = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->id())); + $feed->items = array(); + foreach ($result as $item) { + $feed->items[] = $item->iid; + } + + if ($expected_count !== NULL) { + $feed->item_count = count($feed->items); + $this->assertEqual($expected_count, $feed->item_count, format_string('Total items in feed equal to the total items in database (@val1 != @val2)', array('@val1' => $expected_count, '@val2' => $feed->item_count))); + } + } + + /** + * Confirms an item removal from a feed. + * + * @param \Drupal\aggregator\FeedInterface $feed + * Feed object representing the feed. + */ + public function deleteFeedItems(FeedInterface $feed) { + $this->drupalPostForm('admin/config/services/aggregator/delete/' . $feed->id(), array(), t('Delete items')); + $this->assertRaw(t('The news items from %title have been deleted.', array('%title' => $feed->label())), 'Feed items deleted.'); + } + + /** + * Adds and deletes feed items and ensure that the count is zero. + * + * @param \Drupal\aggregator\FeedInterface $feed + * Feed object representing the feed. + * @param int $expected_count + * Expected number of feed items. + */ + public function updateAndDelete(FeedInterface $feed, $expected_count) { + $this->updateFeedItems($feed, $expected_count); + $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->id()))->fetchField(); + $this->assertTrue($count); + $this->deleteFeedItems($feed); + $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->id()))->fetchField(); + $this->assertTrue($count == 0); + } + + /** + * Checks whether the feed name and URL are unique. + * + * @param string $feed_name + * String containing the feed name to check. + * @param string $feed_url + * String containing the feed url to check. + * + * @return bool + * TRUE if feed is unique. + */ + public function uniqueFeed($feed_name, $feed_url) { + $result = db_query("SELECT COUNT(*) FROM {aggregator_feed} WHERE title = :title AND url = :url", array(':title' => $feed_name, ':url' => $feed_url))->fetchField(); + return (1 == $result); + } + + /** + * Creates a valid OPML file from an array of feeds. + * + * @param array $feeds + * An array of feeds. + * + * @return string + * Path to valid OPML file. + */ + public function getValidOpml(array $feeds) { + // Properly escape URLs so that XML parsers don't choke on them. + foreach ($feeds as &$feed) { + $feed['url[0][value]'] = Html::escape($feed['url[0][value]']); + } + /** + * Does not have an XML declaration, must pass the parser. + */ + $opml = << + + + + + + + + + + + + + + + + + +EOF; + + $path = 'public://valid-opml.xml'; + return file_unmanaged_save_data($opml, $path); + } + + /** + * Creates an invalid OPML file. + * + * @return string + * Path to invalid OPML file. + */ + public function getInvalidOpml() { + $opml = << + + +EOF; + + $path = 'public://invalid-opml.xml'; + return file_unmanaged_save_data($opml, $path); + } + + /** + * Creates a valid but empty OPML file. + * + * @return string + * Path to empty OPML file. + */ + public function getEmptyOpml() { + $opml = << + + + + + + + +EOF; + + $path = 'public://empty-opml.xml'; + return file_unmanaged_save_data($opml, $path); + } + + /** + * Returns a example RSS091 feed. + * + * @return string + * Path to the feed. + */ + public function getRSS091Sample() { + return $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'aggregator') . '/tests/modules/aggregator_test/aggregator_test_rss091.xml'; + } + + /** + * Returns a example Atom feed. + * + * @return string + * Path to the feed. + */ + public function getAtomSample() { + // The content of this sample ATOM feed is based directly off of the + // example provided in RFC 4287. + return $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'aggregator') . '/tests/modules/aggregator_test/aggregator_test_atom.xml'; + } + + /** + * Returns a example feed. + * + * @return string + * Path to the feed. + */ + public function getHtmlEntitiesSample() { + return $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'aggregator') . '/tests/modules/aggregator_test/aggregator_test_title_entities.xml'; + } + + /** + * Creates sample article nodes. + * + * @param int $count + * (optional) The number of nodes to generate. Defaults to five. + */ + public function createSampleNodes($count = 5) { + // Post $count article nodes. + for ($i = 0; $i < $count; $i++) { + $edit = array(); + $edit['title[0][value]'] = $this->randomMachineName(); + $edit['body[0][value]'] = $this->randomMachineName(); + $this->drupalPostForm('node/add/article', $edit, t('Save')); + } + } + + /** + * Enable the plugins coming with aggregator_test module. + */ + public function enableTestPlugins() { + $this->config('aggregator.settings') + ->set('fetcher', 'aggregator_test_fetcher') + ->set('parser', 'aggregator_test_parser') + ->set('processors', array( + 'aggregator_test_processor' => 'aggregator_test_processor', + 'aggregator' => 'aggregator', + )) + ->save(); + } + +} diff --git a/core/modules/aggregator/src/Tests/DeleteFeedItemTest.php b/core/modules/aggregator/tests/src/Functional/DeleteFeedItemTest.php similarity index 96% rename from core/modules/aggregator/src/Tests/DeleteFeedItemTest.php rename to core/modules/aggregator/tests/src/Functional/DeleteFeedItemTest.php index 0261ed5..95202b8 100644 --- a/core/modules/aggregator/src/Tests/DeleteFeedItemTest.php +++ b/core/modules/aggregator/tests/src/Functional/DeleteFeedItemTest.php @@ -1,6 +1,6 @@ autoCreateBasicBlockType) { + $this->createBlockContentType('basic', TRUE); + } + + $this->adminUser = $this->drupalCreateUser($this->permissions); + $this->drupalPlaceBlock('local_actions_block'); + } + + /** + * Creates a custom block. + * + * @param bool|string $title + * (optional) Title of block. When no value is given uses a random name. + * Defaults to FALSE. + * @param string $bundle + * (optional) Bundle name. Defaults to 'basic'. + * @param bool $save + * (optional) Whether to save the block. Defaults to TRUE. + * + * @return \Drupal\block_content\Entity\BlockContent + * Created custom block. + */ + protected function createBlockContent($title = FALSE, $bundle = 'basic', $save = TRUE) { + $title = $title ?: $this->randomMachineName(); + $block_content = BlockContent::create(array( + 'info' => $title, + 'type' => $bundle, + 'langcode' => 'en' + )); + if ($block_content && $save === TRUE) { + $block_content->save(); + } + return $block_content; + } + + /** + * Creates a custom block type (bundle). + * + * @param string $label + * The block type label. + * @param bool $create_body + * Whether or not to create the body field + * + * @return \Drupal\block_content\Entity\BlockContentType + * Created custom block type. + */ + protected function createBlockContentType($label, $create_body = FALSE) { + $bundle = BlockContentType::create(array( + 'id' => $label, + 'label' => $label, + 'revision' => FALSE, + )); + $bundle->save(); + if ($create_body) { + block_content_add_body_field($bundle->id()); + } + return $bundle; + } + +} diff --git a/core/modules/block_content/src/Tests/BlockContentTypeTest.php b/core/modules/block_content/tests/src/Functional/BlockContentTypeTest.php similarity index 99% rename from core/modules/block_content/src/Tests/BlockContentTypeTest.php rename to core/modules/block_content/tests/src/Functional/BlockContentTypeTest.php index 8f6ffe1..cf4bf76 100644 --- a/core/modules/block_content/src/Tests/BlockContentTypeTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentTypeTest.php @@ -1,6 +1,6 @@ createBlockContentType(array('id' => 'basic')); + + $this->adminUser = $this->drupalCreateUser($this->permissions); + + if ($import_test_views) { + ViewTestData::createTestViews(get_class($this), array('block_content_test_views')); + } + } + + /** + * Creates a custom block. + * + * @param array $settings + * (optional) An associative array of settings for the block_content, as + * used in entity_create(). + * + * @return \Drupal\block_content\Entity\BlockContent + * Created custom block. + */ + protected function createBlockContent(array $settings = array()) { + $status = 0; + $settings += array( + 'info' => $this->randomMachineName(), + 'type' => 'basic', + 'langcode' => 'en', + ); + if ($block_content = BlockContent::create($settings)) { + $status = $block_content->save(); + } + $this->assertEqual($status, SAVED_NEW, SafeMarkup::format('Created block content %info.', array('%info' => $block_content->label()))); + return $block_content; + } + + /** + * Creates a custom block type (bundle). + * + * @param array $values + * An array of settings to change from the defaults. + * + * @return \Drupal\block_content\Entity\BlockContentType + * Created custom block type. + */ + protected function createBlockContentType(array $values = array()) { + // Find a non-existent random type name. + if (!isset($values['id'])) { + do { + $id = strtolower($this->randomMachineName(8)); + } while (BlockContentType::load($id)); + } + else { + $id = $values['id']; + } + $values += array( + 'id' => $id, + 'label' => $id, + 'revision' => FALSE + ); + $bundle = BlockContentType::create($values); + $status = $bundle->save(); + block_content_add_body_field($bundle->id()); + + $this->assertEqual($status, SAVED_NEW, SafeMarkup::format('Created block content type %bundle.', array('%bundle' => $bundle->id()))); + return $bundle; + } + +} diff --git a/core/modules/block_content/src/Tests/Views/FieldTypeTest.php b/core/modules/block_content/tests/src/Functional/Views/FieldTypeTest.php similarity index 93% rename from core/modules/block_content/src/Tests/Views/FieldTypeTest.php rename to core/modules/block_content/tests/src/Functional/Views/FieldTypeTest.php index f14653f..f863658 100644 --- a/core/modules/block_content/src/Tests/Views/FieldTypeTest.php +++ b/core/modules/block_content/tests/src/Functional/Views/FieldTypeTest.php @@ -1,6 +1,6 @@ uuid(); + $entity_type_id = $entity->getEntityTypeId(); + $original_data = $entity->toArray(); + // Copy everything to sync. + $this->copyConfig(\Drupal::service('config.storage'), \Drupal::service('config.storage.sync')); + // Delete the configuration from active. Don't worry about side effects of + // deleting config like fields cleaning up field storages. The coming import + // should recreate everything as necessary. + $entity->delete(); + $this->configImporter()->reset()->import(); + $imported_entity = \Drupal::entityManager()->loadEntityByUuid($entity_type_id, $entity_uuid); + $this->assertIdentical($original_data, $imported_entity->toArray()); + } + +} diff --git a/core/modules/config/src/Tests/ConfigDependencyWebTest.php b/core/modules/config/tests/src/Functional/ConfigDependencyWebTest.php similarity index 97% rename from core/modules/config/src/Tests/ConfigDependencyWebTest.php rename to core/modules/config/tests/src/Functional/ConfigDependencyWebTest.php index 5eb5206..c243e9a 100644 --- a/core/modules/config/src/Tests/ConfigDependencyWebTest.php +++ b/core/modules/config/tests/src/Functional/ConfigDependencyWebTest.php @@ -1,16 +1,16 @@ checkConfigSchema($typed_config, $config_name, $config_data); + if ($errors === FALSE) { + // @todo Since the use of this trait is under TestBase, it works. + // Can be fixed as part of https://www.drupal.org/node/2260053. + $this->fail(SafeMarkup::format('No schema for @config_name', array('@config_name' => $config_name))); + return; + } + elseif ($errors === TRUE) { + // @todo Since the use of this trait is under TestBase, it works. + // Can be fixed as part of https://www.drupal.org/node/2260053. + $this->pass(SafeMarkup::format('Schema found for @config_name and values comply with schema.', array('@config_name' => $config_name))); + } + else { + foreach ($errors as $key => $error) { + // @todo Since the use of this trait is under TestBase, it works. + // Can be fixed as part of https://www.drupal.org/node/2260053. + $this->fail(SafeMarkup::format('Schema key @key failed with: @error', array('@key' => $key, '@error' => $error))); + } + } + } + + /** + * Asserts configuration, specified by name, has a valid schema. + * + * @param string $config_name + * The configuration name. + */ + public function assertConfigSchemaByName($config_name) { + $config = $this->config($config_name); + $this->assertConfigSchema(\Drupal::service('config.typed'), $config->getName(), $config->get()); + } + +} diff --git a/core/modules/config/src/Tests/SchemaConfigListenerWebTest.php b/core/modules/config/tests/src/Functional/SchemaConfigListenerWebTest.php similarity index 93% rename from core/modules/config/src/Tests/SchemaConfigListenerWebTest.php rename to core/modules/config/tests/src/Functional/SchemaConfigListenerWebTest.php index 45aabc8..4104606 100644 --- a/core/modules/config/src/Tests/SchemaConfigListenerWebTest.php +++ b/core/modules/config/tests/src/Functional/SchemaConfigListenerWebTest.php @@ -1,16 +1,16 @@ adminUser = $this->drupalCreateUser($this->permissions); + $this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']); + $this->drupalPlaceBlock('page_title_block'); + $this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']); + } + + /** + * Creates a content-type from the UI. + * + * @param string $content_type_name + * Content type human name. + * @param string $content_type_id + * Machine name. + * @param bool $moderated + * TRUE if should be moderated. + * @param string[] $allowed_states + * Array of allowed state IDs. + * @param string $default_state + * Default state. + */ + protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, array $allowed_states = [], $default_state = NULL) { + $this->drupalGet('admin/structure/types'); + $this->clickLink('Add content type'); + $edit = [ + 'name' => $content_type_name, + 'type' => $content_type_id, + ]; + $this->drupalPostForm(NULL, $edit, t('Save content type')); + + if ($moderated) { + $this->enableModerationThroughUi($content_type_id, $allowed_states, $default_state); + } + } + + /** + * Enable moderation for a specified content type, using the UI. + * + * @param string $content_type_id + * Machine name. + * @param string[] $allowed_states + * Array of allowed state IDs. + * @param string $default_state + * Default state. + */ + protected function enableModerationThroughUi($content_type_id, array $allowed_states, $default_state) { + $this->drupalGet('admin/structure/types'); + $this->assertLinkByHref('admin/structure/types/manage/' . $content_type_id . '/moderation'); + $this->drupalGet('admin/structure/types/manage/' . $content_type_id); + $this->assertLinkByHref('admin/structure/types/manage/' . $content_type_id . '/moderation'); + $this->drupalGet('admin/structure/types/manage/' . $content_type_id . '/moderation'); + $this->assertFieldByName('enable_moderation_state'); + $this->assertNoFieldChecked('edit-enable-moderation-state'); + + $edit['enable_moderation_state'] = 1; + + /** @var ModerationState $state */ + foreach (ModerationState::loadMultiple() as $state) { + $key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']'; + $edit[$key] = in_array($state->id(), $allowed_states, TRUE) ? $state->id() : FALSE; + } + + $edit['default_moderation_state'] = $default_state; + + $this->drupalPostForm(NULL, $edit, t('Save')); + } + + /** + * Grants given user permission to create content of given type. + * + * @param \Drupal\Core\Session\AccountInterface $account + * User to grant permission to. + * @param string $content_type_id + * Content type ID. + */ + protected function grantUserPermissionToCreateContentOfType(AccountInterface $account, $content_type_id) { + $role_ids = $account->getRoles(TRUE); + /* @var \Drupal\user\RoleInterface $role */ + $role_id = reset($role_ids); + $role = Role::load($role_id); + $role->grantPermission(sprintf('create %s content', $content_type_id)); + $role->grantPermission(sprintf('edit any %s content', $content_type_id)); + $role->grantPermission(sprintf('delete any %s content', $content_type_id)); + $role->save(); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationStateTransitionsTest.php similarity index 98% rename from core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php rename to core/modules/content_moderation/tests/src/Functional/ModerationStateTransitionsTest.php index 0495e48..e7b0842 100644 --- a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php +++ b/core/modules/content_moderation/tests/src/Functional/ModerationStateTransitionsTest.php @@ -1,6 +1,6 @@ setupLanguages(); + $this->setupBundle(); + $this->enableTranslation(); + $this->setupUsers(); + $this->setupTestFields(); + + $this->manager = $this->container->get('content_translation.manager'); + $this->controller = $this->manager->getTranslationHandler($this->entityTypeId); + + // Rebuild the container so that the new languages are picked up by services + // that hold a list of languages. + $this->rebuildContainer(); + } + + /** + * Adds additional languages. + */ + protected function setupLanguages() { + $this->langcodes = array('it', 'fr'); + foreach ($this->langcodes as $langcode) { + ConfigurableLanguage::createFromLangcode($langcode)->save(); + } + array_unshift($this->langcodes, \Drupal::languageManager()->getDefaultLanguage()->getId()); + } + + /** + * Returns an array of permissions needed for the translator. + */ + protected function getTranslatorPermissions() { + return array_filter(array($this->getTranslatePermission(), 'create content translations', 'update content translations', 'delete content translations')); + } + + /** + * Returns the translate permissions for the current entity and bundle. + */ + protected function getTranslatePermission() { + $entity_type = \Drupal::entityManager()->getDefinition($this->entityTypeId); + if ($permission_granularity = $entity_type->getPermissionGranularity()) { + return $permission_granularity == 'bundle' ? "translate {$this->bundle} {$this->entityTypeId}" : "translate {$this->entityTypeId}"; + } + } + + /** + * Returns an array of permissions needed for the editor. + */ + protected function getEditorPermissions() { + // Every entity-type-specific test needs to define these. + return array(); + } + + /** + * Returns an array of permissions needed for the administrator. + */ + protected function getAdministratorPermissions() { + return array_merge($this->getEditorPermissions(), $this->getTranslatorPermissions(), array('administer content translation')); + } + + /** + * Creates and activates translator, editor and admin users. + */ + protected function setupUsers() { + $this->translator = $this->drupalCreateUser($this->getTranslatorPermissions(), 'translator'); + $this->editor = $this->drupalCreateUser($this->getEditorPermissions(), 'editor'); + $this->administrator = $this->drupalCreateUser($this->getAdministratorPermissions(), 'administrator'); + $this->drupalLogin($this->translator); + } + + /** + * Creates or initializes the bundle date if needed. + */ + protected function setupBundle() { + if (empty($this->bundle)) { + $this->bundle = $this->entityTypeId; + } + } + + /** + * Enables translation for the current entity type and bundle. + */ + protected function enableTranslation() { + // Enable translation for the current entity type and ensure the change is + // picked up. + \Drupal::service('content_translation.manager')->setEnabled($this->entityTypeId, $this->bundle, TRUE); + drupal_static_reset(); + \Drupal::entityManager()->clearCachedDefinitions(); + \Drupal::service('router.builder')->rebuild(); + \Drupal::service('entity.definition_update_manager')->applyUpdates(); + } + + /** + * Creates the test fields. + */ + protected function setupTestFields() { + if (empty($this->fieldName)) { + $this->fieldName = 'field_test_et_ui_test'; + } + FieldStorageConfig::create(array( + 'field_name' => $this->fieldName, + 'type' => 'string', + 'entity_type' => $this->entityTypeId, + 'cardinality' => 1, + ))->save(); + FieldConfig::create([ + 'entity_type' => $this->entityTypeId, + 'field_name' => $this->fieldName, + 'bundle' => $this->bundle, + 'label' => 'Test translatable text-field', + ])->save(); + entity_get_form_display($this->entityTypeId, $this->bundle, 'default') + ->setComponent($this->fieldName, array( + 'type' => 'string_textfield', + 'weight' => 0, + )) + ->save(); + } + + /** + * Creates the entity to be translated. + * + * @param array $values + * An array of initial values for the entity. + * @param string $langcode + * The initial language code of the entity. + * @param string $bundle_name + * (optional) The entity bundle, if the entity uses bundles. Defaults to + * NULL. If left NULL, $this->bundle will be used. + * + * @return string + * The entity id. + */ + protected function createEntity($values, $langcode, $bundle_name = NULL) { + $entity_values = $values; + $entity_values['langcode'] = $langcode; + $entity_type = \Drupal::entityManager()->getDefinition($this->entityTypeId); + if ($bundle_key = $entity_type->getKey('bundle')) { + $entity_values[$bundle_key] = $bundle_name ?: $this->bundle; + } + $controller = $this->container->get('entity.manager')->getStorage($this->entityTypeId); + if (!($controller instanceof SqlContentEntityStorage)) { + foreach ($values as $property => $value) { + if (is_array($value)) { + $entity_values[$property] = array($langcode => $value); + } + } + } + $entity = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId) + ->create($entity_values); + $entity->save(); + return $entity->id(); + } + +} diff --git a/core/modules/content_translation/src/Tests/ContentTranslationUISkipTest.php b/core/modules/content_translation/tests/src/Functional/ContentTranslationUISkipTest.php similarity index 85% rename from core/modules/content_translation/src/Tests/ContentTranslationUISkipTest.php rename to core/modules/content_translation/tests/src/Functional/ContentTranslationUISkipTest.php index 2f8be23..74d5de5 100644 --- a/core/modules/content_translation/src/Tests/ContentTranslationUISkipTest.php +++ b/core/modules/content_translation/tests/src/Functional/ContentTranslationUISkipTest.php @@ -1,15 +1,15 @@ doTestBasicTranslation(); + $this->doTestTranslationOverview(); + $this->doTestOutdatedStatus(); + $this->doTestPublishedStatus(); + $this->doTestAuthoringInfo(); + $this->doTestTranslationEdit(); + $this->doTestTranslationChanged(); + $this->doTestChangedTimeAfterSaveWithoutChanges(); + $this->doTestTranslationDeletion(); + } + + /** + * Tests the basic translation workflow. + */ + protected function doTestBasicTranslation() { + // Create a new test entity with original values in the default language. + $default_langcode = $this->langcodes[0]; + $values[$default_langcode] = $this->getNewEntityValues($default_langcode); + // Create the entity with the editor as owner, so that afterwards a new + // translation is created by the translator and the translation author is + // tested. + $this->drupalLogin($this->editor); + $this->entityId = $this->createEntity($values[$default_langcode], $default_langcode); + $this->drupalLogin($this->translator); + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->assertTrue($entity, 'Entity found in the database.'); + $this->drupalGet($entity->urlInfo()); + $this->assertResponse(200, 'Entity URL is valid.'); + + // Ensure that the content language cache context is not yet added to the + // page. + $this->assertCacheContexts($this->defaultCacheContexts); + + $this->drupalGet($entity->urlInfo('drupal:content-translation-overview')); + $this->assertNoText('Source language', 'Source language column correctly hidden.'); + + $translation = $this->getTranslation($entity, $default_langcode); + foreach ($values[$default_langcode] as $property => $value) { + $stored_value = $this->getValue($translation, $property, $default_langcode); + $value = is_array($value) ? $value[0]['value'] : $value; + $message = format_string('@property correctly stored in the default language.', array('@property' => $property)); + $this->assertEqual($stored_value, $value, $message); + } + + // Add a content translation. + $langcode = 'it'; + $language = ConfigurableLanguage::load($langcode); + $values[$langcode] = $this->getNewEntityValues($langcode); + + $entity_type_id = $entity->getEntityTypeId(); + $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [ + $entity->getEntityTypeId() => $entity->id(), + 'source' => $default_langcode, + 'target' => $langcode + ], array('language' => $language)); + $this->drupalPostForm($add_url, $this->getEditValues($values, $langcode), $this->getFormSubmitActionForNewTranslation($entity, $langcode)); + + // Assert that HTML is escaped in "all languages" in UI after SafeMarkup + // change. + if ($this->testHTMLEscapeForAllLanguages) { + $this->assertNoRaw('<span class="translation-entity-all-languages">(all languages)</span>'); + $this->assertRaw('(all languages)'); + } + + // Ensure that the content language cache context is not yet added to the + // page. + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->drupalGet($entity->urlInfo()); + $this->assertCacheContexts(Cache::mergeContexts(['languages:language_content'], $this->defaultCacheContexts)); + + // Reset the cache of the entity, so that the new translation gets the + // updated values. + $metadata_source_translation = $this->manager->getTranslationMetadata($entity->getTranslation($default_langcode)); + $metadata_target_translation = $this->manager->getTranslationMetadata($entity->getTranslation($langcode)); + + $author_field_name = $entity->hasField('content_translation_uid') ? 'content_translation_uid' : 'uid'; + if ($entity->getFieldDefinition($author_field_name)->isTranslatable()) { + $this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->translator->id(), + SafeMarkup::format('Author of the target translation @langcode correctly stored for translatable owner field.', array('@langcode' => $langcode))); + + $this->assertNotEqual($metadata_target_translation->getAuthor()->id(), $metadata_source_translation->getAuthor()->id(), + SafeMarkup::format('Author of the target translation @target different from the author of the source translation @source for translatable owner field.', + array('@target' => $langcode, '@source' => $default_langcode))); + } + else { + $this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->editor->id(), 'Author of the entity remained untouched after translation for non translatable owner field.'); + } + + $created_field_name = $entity->hasField('content_translation_created') ? 'content_translation_created' : 'created'; + if ($entity->getFieldDefinition($created_field_name)->isTranslatable()) { + $this->assertTrue($metadata_target_translation->getCreatedTime() > $metadata_source_translation->getCreatedTime(), + SafeMarkup::format('Translation creation timestamp of the target translation @target is newer than the creation timestamp of the source translation @source for translatable created field.', + array('@target' => $langcode, '@source' => $default_langcode))); + } + else { + $this->assertEqual($metadata_target_translation->getCreatedTime(), $metadata_source_translation->getCreatedTime(), 'Creation timestamp of the entity remained untouched after translation for non translatable created field.'); + } + + if ($this->testLanguageSelector) { + $this->assertNoFieldByXPath('//select[@id="edit-langcode-0-value"]', NULL, 'Language selector correctly disabled on translations.'); + } + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->drupalGet($entity->urlInfo('drupal:content-translation-overview')); + $this->assertNoText('Source language', 'Source language column correctly hidden.'); + + // Switch the source language. + $langcode = 'fr'; + $language = ConfigurableLanguage::load($langcode); + $source_langcode = 'it'; + $edit = array('source_langcode[source]' => $source_langcode); + $entity_type_id = $entity->getEntityTypeId(); + $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [ + $entity->getEntityTypeId() => $entity->id(), + 'source' => $default_langcode, + 'target' => $langcode + ], array('language' => $language)); + // This does not save anything, it merely reloads the form and fills in the + // fields with the values from the different source language. + $this->drupalPostForm($add_url, $edit, t('Change')); + $this->assertFieldByXPath("//input[@name=\"{$this->fieldName}[0][value]\"]", $values[$source_langcode][$this->fieldName][0]['value'], 'Source language correctly switched.'); + + // Add another translation and mark the other ones as outdated. + $values[$langcode] = $this->getNewEntityValues($langcode); + $edit = $this->getEditValues($values, $langcode) + array('content_translation[retranslate]' => TRUE); + $entity_type_id = $entity->getEntityTypeId(); + $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [ + $entity->getEntityTypeId() => $entity->id(), + 'source' => $source_langcode, + 'target' => $langcode + ], array('language' => $language)); + $this->drupalPostForm($add_url, $edit, $this->getFormSubmitActionForNewTranslation($entity, $langcode)); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->drupalGet($entity->urlInfo('drupal:content-translation-overview')); + $this->assertText('Source language', 'Source language column correctly shown.'); + + // Check that the entered values have been correctly stored. + foreach ($values as $langcode => $property_values) { + $translation = $this->getTranslation($entity, $langcode); + foreach ($property_values as $property => $value) { + $stored_value = $this->getValue($translation, $property, $langcode); + $value = is_array($value) ? $value[0]['value'] : $value; + $message = format_string('%property correctly stored with language %language.', array('%property' => $property, '%language' => $langcode)); + $this->assertEqual($stored_value, $value, $message); + } + } + } + + /** + * Tests that the translation overview shows the correct values. + */ + protected function doTestTranslationOverview() { + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $translate_url = $entity->urlInfo('drupal:content-translation-overview'); + $this->drupalGet($translate_url); + $translate_url->setAbsolute(FALSE); + + foreach ($this->langcodes as $langcode) { + if ($entity->hasTranslation($langcode)) { + $language = new Language(array('id' => $langcode)); + $view_url = $entity->url('canonical', ['language' => $language]); + $elements = $this->xpath('//table//a[@href=:href]', [':href' => $view_url]); + $this->assertEqual((string) $elements[0], $entity->getTranslation($langcode)->label(), format_string('Label correctly shown for %language translation.', array('%language' => $langcode))); + $edit_path = $entity->url('edit-form', array('language' => $language)); + $elements = $this->xpath('//table//ul[@class="dropbutton"]/li/a[@href=:href]', array(':href' => $edit_path)); + $this->assertEqual((string) $elements[0], t('Edit'), format_string('Edit link correct for %language translation.', array('%language' => $langcode))); + } + } + } + + /** + * Tests up-to-date status tracking. + */ + protected function doTestOutdatedStatus() { + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $langcode = 'fr'; + $languages = \Drupal::languageManager()->getLanguages(); + + // Mark translations as outdated. + $edit = array('content_translation[retranslate]' => TRUE); + $edit_path = $entity->urlInfo('edit-form', array('language' => $languages[$langcode])); + $this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode)); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + + // Check that every translation has the correct "outdated" status, and that + // the Translation fieldset is open if the translation is "outdated". + foreach ($this->langcodes as $added_langcode) { + $url = $entity->urlInfo('edit-form', array('language' => ConfigurableLanguage::load($added_langcode))); + $this->drupalGet($url); + if ($added_langcode == $langcode) { + $this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is not checked by default.'); + $this->assertFalse($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab should be collapsed by default.'); + } + else { + $this->assertFieldByXPath('//input[@name="content_translation[outdated]"]', TRUE, 'The translate flag is checked by default.'); + $this->assertTrue($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab is correctly expanded when the translation is outdated.'); + $edit = array('content_translation[outdated]' => FALSE); + $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $added_langcode)); + $this->drupalGet($url); + $this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is now shown.'); + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($added_langcode))->isOutdated(), 'The "outdated" status has been correctly stored.'); + } + } + } + + /** + * Tests the translation publishing status. + */ + protected function doTestPublishedStatus() { + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + + // Unpublish translations. + foreach ($this->langcodes as $index => $langcode) { + if ($index > 0) { + $url = $entity->urlInfo('edit-form', array('language' => ConfigurableLanguage::load($langcode))); + $edit = array('content_translation[status]' => FALSE); + $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode)); + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($langcode))->isPublished(), 'The translation has been correctly unpublished.'); + } + } + + // Check that the last published translation cannot be unpublished. + $this->drupalGet($entity->urlInfo('edit-form')); + $this->assertFieldByXPath('//input[@name="content_translation[status]" and @disabled="disabled"]', TRUE, 'The last translation is published and cannot be unpublished.'); + } + + /** + * Tests the translation authoring information. + */ + protected function doTestAuthoringInfo() { + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $values = array(); + + // Post different authoring information for each translation. + foreach ($this->langcodes as $index => $langcode) { + $user = $this->drupalCreateUser(); + $values[$langcode] = array( + 'uid' => $user->id(), + 'created' => REQUEST_TIME - mt_rand(0, 1000), + ); + $edit = array( + 'content_translation[uid]' => $user->getUsername(), + 'content_translation[created]' => format_date($values[$langcode]['created'], 'custom', 'Y-m-d H:i:s O'), + ); + $url = $entity->urlInfo('edit-form', array('language' => ConfigurableLanguage::load($langcode))); + $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode)); + } + + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + foreach ($this->langcodes as $langcode) { + $metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode)); + $this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly stored.'); + $this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly stored.'); + } + + // Try to post non valid values and check that they are rejected. + $langcode = 'en'; + $edit = array( + // User names have by default length 8. + 'content_translation[uid]' => $this->randomMachineName(12), + 'content_translation[created]' => '19/11/1978', + ); + $this->drupalPostForm($entity->urlInfo('edit-form'), $edit, $this->getFormSubmitAction($entity, $langcode)); + $this->assertTrue($this->xpath('//div[contains(@class, "error")]//ul'), 'Invalid values generate a list of form errors.'); + $metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode)); + $this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly kept.'); + $this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly kept.'); + } + + /** + * Tests translation deletion. + */ + protected function doTestTranslationDeletion() { + // Confirm and delete a translation. + $this->drupalLogin($this->translator); + $langcode = 'fr'; + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $language = ConfigurableLanguage::load($langcode); + $url = $entity->urlInfo('edit-form', array('language' => $language)); + $this->drupalPostForm($url, array(), t('Delete translation')); + $this->drupalPostForm(NULL, array(), t('Delete @language translation', array('@language' => $language->getName()))); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId, TRUE); + if ($this->assertTrue(is_object($entity), 'Entity found')) { + $translations = $entity->getTranslationLanguages(); + $this->assertTrue(count($translations) == 2 && empty($translations[$langcode]), 'Translation successfully deleted.'); + } + + // Check that the translator cannot delete the original translation. + $args = [$this->entityTypeId => $entity->id(), 'language' => 'en']; + $this->drupalGet(Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", $args)); + $this->assertResponse(403); + } + + /** + * Returns an array of entity field values to be tested. + */ + protected function getNewEntityValues($langcode) { + return array($this->fieldName => array(array('value' => $this->randomMachineName(16)))); + } + + /** + * Returns an edit array containing the values to be posted. + */ + protected function getEditValues($values, $langcode, $new = FALSE) { + $edit = $values[$langcode]; + $langcode = $new ? LanguageInterface::LANGCODE_NOT_SPECIFIED : $langcode; + foreach ($values[$langcode] as $property => $value) { + if (is_array($value)) { + $edit["{$property}[0][value]"] = $value[0]['value']; + unset($edit[$property]); + } + } + return $edit; + } + + /** + * Returns the form action value when submitting a new translation. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being tested. + * @param string $langcode + * Language code for the form. + * + * @return string + * Name of the button to hit. + */ + protected function getFormSubmitActionForNewTranslation(EntityInterface $entity, $langcode) { + $entity->addTranslation($langcode, $entity->toArray()); + return $this->getFormSubmitAction($entity, $langcode); + } + + /** + * Returns the form action value to be used to submit the entity form. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being tested. + * @param string $langcode + * Language code for the form. + * + * @return string + * Name of the button to hit. + */ + protected function getFormSubmitAction(EntityInterface $entity, $langcode) { + return t('Save') . $this->getFormSubmitSuffix($entity, $langcode); + } + + /** + * Returns appropriate submit button suffix based on translatability. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being tested. + * @param string $langcode + * Language code for the form. + * + * @return string + * Submit button suffix based on translatability. + */ + protected function getFormSubmitSuffix(EntityInterface $entity, $langcode) { + return ''; + } + + /** + * Returns the translation object to use to retrieve the translated values. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being tested. + * @param string $langcode + * The language code identifying the translation to be retrieved. + * + * @return \Drupal\Core\TypedData\TranslatableInterface + * The translation object to act on. + */ + protected function getTranslation(EntityInterface $entity, $langcode) { + return $entity->getTranslation($langcode); + } + + /** + * Returns the value for the specified property in the given language. + * + * @param \Drupal\Core\Entity\EntityInterface $translation + * The translation object the property value should be retrieved from. + * @param string $property + * The property name. + * @param string $langcode + * The property value. + * + * @return + * The property value. + */ + protected function getValue(EntityInterface $translation, $property, $langcode) { + $key = $property == 'user_id' ? 'target_id' : 'value'; + return $translation->get($property)->{$key}; + } + + /** + * Returns the name of the field that implements the changed timestamp. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being tested. + * + * @return string + * The field name. + */ + protected function getChangedFieldName($entity) { + return $entity->hasField('content_translation_changed') ? 'content_translation_changed' : 'changed'; + } + + /** + * Tests edit content translation. + */ + protected function doTestTranslationEdit() { + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $languages = $this->container->get('language_manager')->getLanguages(); + + foreach ($this->langcodes as $langcode) { + // We only want to test the title for non-english translations. + if ($langcode != 'en') { + $options = array('language' => $languages[$langcode]); + $url = $entity->urlInfo('edit-form', $options); + $this->drupalGet($url); + + $this->assertRaw($entity->getTranslation($langcode)->label()); + } + } + } + + /** + * Tests the basic translation workflow. + */ + protected function doTestTranslationChanged() { + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $changed_field_name = $this->getChangedFieldName($entity); + $definition = $entity->getFieldDefinition($changed_field_name); + $config = $definition->getConfig($entity->bundle()); + + foreach ([FALSE, TRUE] as $translatable_changed_field) { + if ($definition->isTranslatable()) { + // For entities defining a translatable changed field we want to test + // the correct behavior of that field even if the translatability is + // revoked. In that case the changed timestamp should be synchronized + // across all translations. + $config->setTranslatable($translatable_changed_field); + $config->save(); + } + elseif ($translatable_changed_field) { + // For entities defining a non-translatable changed field we cannot + // declare the field as translatable on the fly by modifying its config + // because the schema doesn't support this. + break; + } + + foreach ($entity->getTranslationLanguages() as $language) { + // Ensure different timestamps. + sleep(1); + + $langcode = $language->getId(); + + $edit = array( + $this->fieldName . '[0][value]' => $this->randomString(), + ); + $edit_path = $entity->urlInfo('edit-form', array('language' => $language)); + $this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode)); + + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->assertEqual( + $entity->getChangedTimeAcrossTranslations(), $entity->getTranslation($langcode)->getChangedTime(), + format_string('Changed time for language %language is the latest change over all languages.', array('%language' => $language->getName())) + ); + } + + $timestamps = array(); + foreach ($entity->getTranslationLanguages() as $language) { + $next_timestamp = $entity->getTranslation($language->getId())->getChangedTime(); + if (!in_array($next_timestamp, $timestamps)) { + $timestamps[] = $next_timestamp; + } + } + + if ($translatable_changed_field) { + $this->assertEqual( + count($timestamps), count($entity->getTranslationLanguages()), + 'All timestamps from all languages are different.' + ); + } + else { + $this->assertEqual( + count($timestamps), 1, + 'All timestamps from all languages are identical.' + ); + } + } + } + + /** + * Test the changed time after API and FORM save without changes. + */ + public function doTestChangedTimeAfterSaveWithoutChanges() { + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + // Test only entities, which implement the EntityChangedInterface. + if ($entity->getEntityType()->isSubclassOf('Drupal\Core\Entity\EntityChangedInterface')) { + $changed_timestamp = $entity->getChangedTime(); + + $entity->save(); + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->assertEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time wasn\'t updated after API save without changes.'); + + // Ensure different save timestamps. + sleep(1); + + // Save the entity on the regular edit form. + $language = $entity->language(); + $edit_path = $entity->urlInfo('edit-form', array('language' => $language)); + $this->drupalPostForm($edit_path, [], $this->getFormSubmitAction($entity, $language->getId())); + + $storage->resetCache([$this->entityId]); + $entity = $storage->load($this->entityId); + $this->assertNotEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time was updated after form save without changes.'); + } + } + +} diff --git a/core/modules/content_translation/src/Tests/Views/ContentTranslationViewsUITest.php b/core/modules/content_translation/tests/src/Functional/Views/ContentTranslationViewsUITest.php similarity index 92% rename from core/modules/content_translation/src/Tests/Views/ContentTranslationViewsUITest.php rename to core/modules/content_translation/tests/src/Functional/Views/ContentTranslationViewsUITest.php index 36d880e..ad0e42b 100644 --- a/core/modules/content_translation/src/Tests/Views/ContentTranslationViewsUITest.php +++ b/core/modules/content_translation/tests/src/Functional/Views/ContentTranslationViewsUITest.php @@ -1,6 +1,6 @@ drupalCreateUser([ + 'access content', + 'view test entity', + 'administer entity_test content', + 'administer entity_test form display', + 'administer content types', + 'administer node fields', + ]); + $this->drupalLogin($web_user); + + // Create a field with settings to validate. + $this->createField(); + + $this->dateFormatter = $this->container->get('date.formatter'); + } + + /** + * Creates a date test field. + */ + protected function createField() { + $field_name = Unicode::strtolower($this->randomMachineName()); + $type = $this->getTestFieldType(); + $widget_type = $formatter_type = $type . '_default'; + + $this->fieldStorage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'entity_test', + 'type' => $type, + 'settings' => ['datetime_type' => DateRangeItem::DATETIME_TYPE_DATE], + ]); + $this->fieldStorage->save(); + $this->field = FieldConfig::create([ + 'field_storage' => $this->fieldStorage, + 'bundle' => 'entity_test', + 'required' => TRUE, + ]); + $this->field->save(); + + EntityFormDisplay::load('entity_test.entity_test.default') + ->setComponent($field_name, ['type' => $widget_type]) + ->save(); + + $this->displayOptions = [ + 'type' => $formatter_type, + 'label' => 'hidden', + 'settings' => ['format_type' => 'medium'] + $this->defaultSettings, + ]; + EntityViewDisplay::create([ + 'targetEntityType' => $this->field->getTargetEntityTypeId(), + 'bundle' => $this->field->getTargetBundle(), + 'mode' => 'full', + 'status' => TRUE, + ])->setComponent($field_name, $this->displayOptions) + ->save(); + } + + /** + * Renders a entity_test and sets the output in the internal browser. + * + * @param int $id + * The entity_test ID to render. + * @param string $view_mode + * (optional) The view mode to use for rendering. Defaults to 'full'. + * @param bool $reset + * (optional) Whether to reset the entity_test controller cache. Defaults to + * TRUE to simplify testing. + */ + protected function renderTestEntity($id, $view_mode = 'full', $reset = TRUE) { + if ($reset) { + $this->container->get('entity_type.manager')->getStorage('entity_test')->resetCache([$id]); + } + $entity = EntityTest::load($id); + $display = EntityViewDisplay::collectRenderDisplay($entity, $view_mode); + $build = $display->build($entity); + $output = $this->container->get('renderer')->renderRoot($build); + $this->setRawContent($output); + $this->verbose($output); + } + + /** + * Sets the site timezone to a given timezone. + * + * @param string $timezone + * The timezone identifier to set. + */ + protected function setSiteTimezone($timezone) { + // Set an explicit site timezone, and disallow per-user timezones. + $this->config('system.date') + ->set('timezone.user.configurable', 0) + ->set('timezone.default', $timezone) + ->save(); + } + +} diff --git a/core/modules/datetime/src/Tests/Views/ArgumentDateTimeTest.php b/core/modules/datetime/tests/src/Functional/Views/ArgumentDateTimeTest.php similarity index 99% rename from core/modules/datetime/src/Tests/Views/ArgumentDateTimeTest.php rename to core/modules/datetime/tests/src/Functional/Views/ArgumentDateTimeTest.php index e3a17bb..5cdbc33 100644 --- a/core/modules/datetime/src/Tests/Views/ArgumentDateTimeTest.php +++ b/core/modules/datetime/tests/src/Functional/Views/ArgumentDateTimeTest.php @@ -1,6 +1,6 @@ 'page', + 'name' => 'page' + ]); + $node_type->save(); + $fieldStorage = FieldStorageConfig::create([ + 'field_name' => static::$field_name, + 'entity_type' => 'node', + 'type' => 'datetime', + 'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME], + ]); + $fieldStorage->save(); + $field = FieldConfig::create([ + 'field_storage' => $fieldStorage, + 'bundle' => 'page', + 'required' => TRUE, + ]); + $field->save(); + + // Views needs to be aware of the new field. + $this->container->get('views.views_data')->clear(); + + // Set column map. + $this->map = [ + 'nid' => 'nid', + ]; + + // Load test views. + ViewTestData::createTestViews(get_class($this), ['datetime_test']); + } + +} diff --git a/core/modules/datetime/src/Tests/Views/FilterDateTest.php b/core/modules/datetime/tests/src/Functional/Views/FilterDateTest.php similarity index 98% rename from core/modules/datetime/src/Tests/Views/FilterDateTest.php rename to core/modules/datetime/tests/src/Functional/Views/FilterDateTest.php index 2a8d7cd..71bdfbe 100644 --- a/core/modules/datetime/src/Tests/Views/FilterDateTest.php +++ b/core/modules/datetime/tests/src/Functional/Views/FilterDateTest.php @@ -1,6 +1,6 @@ $field_name, + 'type' => 'entity_reference', + 'entity_type' => $entity_type, + 'cardinality' => $cardinality, + 'settings' => array( + 'target_type' => $target_entity_type, + ), + ))->save(); + } + if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) { + FieldConfig::create(array( + 'field_name' => $field_name, + 'entity_type' => $entity_type, + 'bundle' => $bundle, + 'label' => $field_label, + 'settings' => array( + 'handler' => $selection_handler, + 'handler_settings' => $selection_handler_settings, + ), + ))->save(); + } + } + +} diff --git a/core/modules/field/src/Tests/EntityReference/EntityReferenceXSSTest.php b/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceXSSTest.php similarity index 94% rename from core/modules/field/src/Tests/EntityReference/EntityReferenceXSSTest.php rename to core/modules/field/tests/src/Functional/EntityReference/EntityReferenceXSSTest.php index f72312f..ae32450 100644 --- a/core/modules/field/src/Tests/EntityReference/EntityReferenceXSSTest.php +++ b/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceXSSTest.php @@ -1,17 +1,17 @@ container->get('entity_type.manager') + ->getStorage($entity->getEntityTypeId()); + $storage->resetCache([$entity->id()]); + $e = $storage->load($entity->id()); + + $field = $values = $e->getTranslation($langcode)->$field_name; + // Filter out empty values so that they don't mess with the assertions. + $field->filterEmptyItems(); + $values = $field->getValue(); + $this->assertEqual(count($values), count($expected_values), 'Expected number of values were saved.'); + foreach ($expected_values as $key => $value) { + $this->assertEqual($values[$key][$column], $value, format_string('Value @value was saved correctly.', array('@value' => $value))); + } + } + +} diff --git a/core/modules/field/src/Tests/String/StringFieldTest.php b/core/modules/field/tests/src/Functional/String/StringFieldTest.php similarity index 95% rename from core/modules/field/src/Tests/String/StringFieldTest.php rename to core/modules/field/tests/src/Functional/String/StringFieldTest.php index 0ac5748..3d1b62d 100644 --- a/core/modules/field/src/Tests/String/StringFieldTest.php +++ b/core/modules/field/tests/src/Functional/String/StringFieldTest.php @@ -1,11 +1,11 @@ 'page', + 'name' => 'page', + ])->save(); + + ViewTestData::createTestViews(get_class($this), array('field_test_views')); + } + + function setUpFieldStorages($amount = 3, $type = 'string') { + // Create three fields. + $field_names = array(); + for ($i = 0; $i < $amount; $i++) { + $field_names[$i] = 'field_name_' . $i; + $this->fieldStorages[$i] = FieldStorageConfig::create(array( + 'field_name' => $field_names[$i], + 'entity_type' => 'node', + 'type' => $type, + )); + $this->fieldStorages[$i]->save(); + } + return $field_names; + } + + function setUpFields($bundle = 'page') { + foreach ($this->fieldStorages as $key => $field_storage) { + $this->fields[$key] = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => $bundle, + ]); + $this->fields[$key]->save(); + } + } + +} diff --git a/core/modules/field/src/Tests/Views/FieldUITest.php b/core/modules/field/tests/src/Functional/Views/FieldUITest.php similarity index 99% rename from core/modules/field/src/Tests/Views/FieldUITest.php rename to core/modules/field/tests/src/Functional/Views/FieldUITest.php index c47bf19..6e50a5c 100644 --- a/core/modules/field/src/Tests/Views/FieldUITest.php +++ b/core/modules/field/tests/src/Functional/Views/FieldUITest.php @@ -1,6 +1,6 @@ randomString(); + $initial_edit = array( + 'new_storage_type' => $field_type, + 'label' => $label, + 'field_name' => $field_name, + ); + + // Allow the caller to set a NULL path in case they navigated to the right + // page before calling this method. + if ($bundle_path !== NULL) { + $bundle_path = "$bundle_path/fields/add-field"; + } + + // First step: 'Add field' page. + $this->drupalPostForm($bundle_path, $initial_edit, t('Save and continue')); + $this->assertRaw(t('These settings apply to the %label field everywhere it is used.', array('%label' => $label)), 'Storage settings page was displayed.'); + // Test Breadcrumbs. + $this->assertLink($label, 0, 'Field label is correct in the breadcrumb of the storage settings page.'); + + // Second step: 'Storage settings' form. + $this->drupalPostForm(NULL, $storage_edit, t('Save field settings')); + $this->assertRaw(t('Updated field %label field settings.', array('%label' => $label)), 'Redirected to field settings page.'); + + // Third step: 'Field settings' form. + $this->drupalPostForm(NULL, $field_edit, t('Save settings')); + $this->assertRaw(t('Saved %label configuration.', array('%label' => $label)), 'Redirected to "Manage fields" page.'); + + // Check that the field appears in the overview form. + $this->assertFieldByXPath('//table[@id="field-overview"]//tr/td[1]', $label, 'Field was created and appears in the overview page.'); + } + + /** + * Adds an existing field through the Field UI. + * + * @param string $bundle_path + * Admin path of the bundle that the field is to be attached to. + * @param string $existing_storage_name + * The name of the existing field storage for which we want to add a new + * field. + * @param string $label + * (optional) The label of the new field. Defaults to a random string. + * @param array $field_edit + * (optional) $edit parameter for drupalPostForm() on the second step + * ('Field settings' form). + */ + public function fieldUIAddExistingField($bundle_path, $existing_storage_name, $label = NULL, array $field_edit = array()) { + $label = $label ?: $this->randomString(); + $initial_edit = array( + 'existing_storage_name' => $existing_storage_name, + 'existing_storage_label' => $label, + ); + + // First step: 'Re-use existing field' on the 'Add field' page. + $this->drupalPostForm("$bundle_path/fields/add-field", $initial_edit, t('Save and continue')); + // Set the main content to only the content region because the label can + // contain HTML which will be auto-escaped by Twig. + $main_content = $this->cssSelect('.region-content'); + $this->setRawContent(reset($main_content)->asXml()); + $this->assertRaw('field-config-edit-form', 'The field config edit form is present.'); + $this->assertNoRaw('&lt;', 'The page does not have double escaped HTML tags.'); + + // Second step: 'Field settings' form. + $this->drupalPostForm(NULL, $field_edit, t('Save settings')); + $this->assertRaw(t('Saved %label configuration.', array('%label' => $label)), 'Redirected to "Manage fields" page.'); + + // Check that the field appears in the overview form. + $this->assertFieldByXPath('//table[@id="field-overview"]//tr/td[1]', $label, 'Field was created and appears in the overview page.'); + } + + /** + * Deletes a field through the Field UI. + * + * @param string $bundle_path + * Admin path of the bundle that the field is to be deleted from. + * @param string $field_name + * The name of the field. + * @param string $label + * The label of the field. + * @param string $bundle_label + * The label of the bundle. + */ + public function fieldUIDeleteField($bundle_path, $field_name, $label, $bundle_label) { + // Display confirmation form. + $this->drupalGet("$bundle_path/fields/$field_name/delete"); + $this->assertRaw(t('Are you sure you want to delete the field %label', array('%label' => $label)), 'Delete confirmation was found.'); + + // Test Breadcrumbs. + $this->assertLink($label, 0, 'Field label is correct in the breadcrumb of the field delete page.'); + + // Submit confirmation form. + $this->drupalPostForm(NULL, array(), t('Delete')); + $this->assertRaw(t('The field %label has been deleted from the %type content type.', array('%label' => $label, '%type' => $bundle_label)), 'Delete message was found.'); + + // Check that the field does not appear in the overview form. + $this->assertNoFieldByXPath('//table[@id="field-overview"]//span[@class="label-field"]', $label, 'Field does not appear in the overview page.'); + } + +} diff --git a/core/modules/field_ui/src/Tests/ManageFieldsTest.php b/core/modules/field_ui/tests/src/Functional/ManageFieldsTest.php similarity index 99% rename from core/modules/field_ui/src/Tests/ManageFieldsTest.php rename to core/modules/field_ui/tests/src/Functional/ManageFieldsTest.php index cbe9f53..b84f56c 100644 --- a/core/modules/field_ui/src/Tests/ManageFieldsTest.php +++ b/core/modules/field_ui/tests/src/Functional/ManageFieldsTest.php @@ -1,6 +1,6 @@ adminUser = $this->drupalCreateUser(array('access content', 'access administration pages', 'administer site configuration', 'administer users', 'administer permissions', 'administer content types', 'administer node fields', 'administer node display', 'administer nodes', 'bypass node access')); + $this->drupalLogin($this->adminUser); + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + } + + /** + * Retrieves a sample file of the specified type. + * + * @return \Drupal\file\FileInterface + */ + function getTestFile($type_name, $size = NULL) { + // Get a file to upload. + $file = current($this->drupalGetTestFiles($type_name, $size)); + + // Add a filesize property to files as would be read by + // \Drupal\file\Entity\File::load(). + $file->filesize = filesize($file->uri); + + return File::create((array) $file); + } + + /** + * Retrieves the fid of the last inserted file. + */ + function getLastFileId() { + return (int) db_query('SELECT MAX(fid) FROM {file_managed}')->fetchField(); + } + + /** + * Creates a new file field. + * + * @param string $name + * The name of the new field (all lowercase), exclude the "field_" prefix. + * @param string $entity_type + * The entity type. + * @param string $bundle + * The bundle that this field will be added to. + * @param array $storage_settings + * A list of field storage settings that will be added to the defaults. + * @param array $field_settings + * A list of instance settings that will be added to the instance defaults. + * @param array $widget_settings + * A list of widget settings that will be added to the widget defaults. + */ + function createFileField($name, $entity_type, $bundle, $storage_settings = array(), $field_settings = array(), $widget_settings = array()) { + $field_storage = FieldStorageConfig::create(array( + 'entity_type' => $entity_type, + 'field_name' => $name, + 'type' => 'file', + 'settings' => $storage_settings, + 'cardinality' => !empty($storage_settings['cardinality']) ? $storage_settings['cardinality'] : 1, + )); + $field_storage->save(); + + $this->attachFileField($name, $entity_type, $bundle, $field_settings, $widget_settings); + return $field_storage; + } + + /** + * Attaches a file field to an entity. + * + * @param string $name + * The name of the new field (all lowercase), exclude the "field_" prefix. + * @param string $entity_type + * The entity type this field will be added to. + * @param string $bundle + * The bundle this field will be added to. + * @param array $field_settings + * A list of field settings that will be added to the defaults. + * @param array $widget_settings + * A list of widget settings that will be added to the widget defaults. + */ + function attachFileField($name, $entity_type, $bundle, $field_settings = array(), $widget_settings = array()) { + $field = array( + 'field_name' => $name, + 'label' => $name, + 'entity_type' => $entity_type, + 'bundle' => $bundle, + 'required' => !empty($field_settings['required']), + 'settings' => $field_settings, + ); + FieldConfig::create($field)->save(); + + entity_get_form_display($entity_type, $bundle, 'default') + ->setComponent($name, array( + 'type' => 'file_generic', + 'settings' => $widget_settings, + )) + ->save(); + // Assign display settings. + entity_get_display($entity_type, $bundle, 'default') + ->setComponent($name, array( + 'label' => 'hidden', + 'type' => 'file_default', + )) + ->save(); + } + + /** + * Updates an existing file field with new settings. + */ + function updateFileField($name, $type_name, $field_settings = array(), $widget_settings = array()) { + $field = FieldConfig::loadByName('node', $type_name, $name); + $field->setSettings(array_merge($field->getSettings(), $field_settings)); + $field->save(); + + entity_get_form_display('node', $type_name, 'default') + ->setComponent($name, array( + 'settings' => $widget_settings, + )) + ->save(); + } + + /** + * Uploads a file to a node. + * + * @param \Drupal\file\FileInterface $file + * The File to be uploaded. + * @param string $field_name + * The name of the field on which the files should be saved. + * @param $nid_or_type + * A numeric node id to upload files to an existing node, or a string + * indicating the desired bundle for a new node. + * @param bool $new_revision + * The revision number. + * @param array $extras + * Additional values when a new node is created. + * + * @return int + * The node id. + */ + function uploadNodeFile(FileInterface $file, $field_name, $nid_or_type, $new_revision = TRUE, array $extras = array()) { + return $this->uploadNodeFiles([$file], $field_name, $nid_or_type, $new_revision, $extras); + } + + /** + * Uploads multiple files to a node. + * + * @param \Drupal\file\FileInterface[] $files + * The files to be uploaded. + * @param string $field_name + * The name of the field on which the files should be saved. + * @param $nid_or_type + * A numeric node id to upload files to an existing node, or a string + * indicating the desired bundle for a new node. + * @param bool $new_revision + * The revision number. + * @param array $extras + * Additional values when a new node is created. + * + * @return int + * The node id. + */ + function uploadNodeFiles(array $files, $field_name, $nid_or_type, $new_revision = TRUE, array $extras = array()) { + $edit = array( + 'title[0][value]' => $this->randomMachineName(), + 'revision' => (string) (int) $new_revision, + ); + + $node_storage = $this->container->get('entity.manager')->getStorage('node'); + if (is_numeric($nid_or_type)) { + $nid = $nid_or_type; + $node_storage->resetCache(array($nid)); + $node = $node_storage->load($nid); + } + else { + // Add a new node. + $extras['type'] = $nid_or_type; + $node = $this->drupalCreateNode($extras); + $nid = $node->id(); + // Save at least one revision to better simulate a real site. + $node->setNewRevision(); + $node->save(); + $node_storage->resetCache(array($nid)); + $node = $node_storage->load($nid); + $this->assertNotEqual($nid, $node->getRevisionId(), 'Node revision exists.'); + } + + // Attach files to the node. + $field_storage = FieldStorageConfig::loadByName('node', $field_name); + // File input name depends on number of files already uploaded. + $field_num = count($node->{$field_name}); + $name = 'files[' . $field_name . "_$field_num]"; + if ($field_storage->getCardinality() != 1) { + $name .= '[]'; + } + foreach ($files as $file) { + $file_path = $this->container->get('file_system')->realpath($file->getFileUri()); + if (count($files) == 1) { + $edit[$name] = $file_path; + } + else { + $edit[$name][] = $file_path; + } + } + $this->drupalPostForm("node/$nid/edit", $edit, t('Save and keep published')); + + return $nid; + } + + /** + * Removes a file from a node. + * + * Note that if replacing a file, it must first be removed then added again. + */ + function removeNodeFile($nid, $new_revision = TRUE) { + $edit = array( + 'revision' => (string) (int) $new_revision, + ); + + $this->drupalPostForm('node/' . $nid . '/edit', array(), t('Remove')); + $this->drupalPostForm(NULL, $edit, t('Save and keep published')); + } + + /** + * Replaces a file within a node. + */ + function replaceNodeFile($file, $field_name, $nid, $new_revision = TRUE) { + $edit = array( + 'files[' . $field_name . '_0]' => drupal_realpath($file->getFileUri()), + 'revision' => (string) (int) $new_revision, + ); + + $this->drupalPostForm('node/' . $nid . '/edit', array(), t('Remove')); + $this->drupalPostForm(NULL, $edit, t('Save and keep published')); + } + + /** + * Asserts that a file exists physically on disk. + */ + function assertFileExists($file, $message = NULL) { + $message = isset($message) ? $message : format_string('File %file exists on the disk.', array('%file' => $file->getFileUri())); + $this->assertTrue(is_file($file->getFileUri()), $message); + } + + /** + * Asserts that a file exists in the database. + */ + function assertFileEntryExists($file, $message = NULL) { + $this->container->get('entity.manager')->getStorage('file')->resetCache(); + $db_file = File::load($file->id()); + $message = isset($message) ? $message : format_string('File %file exists in database at the correct path.', array('%file' => $file->getFileUri())); + $this->assertEqual($db_file->getFileUri(), $file->getFileUri(), $message); + } + + /** + * Asserts that a file does not exist on disk. + */ + function assertFileNotExists($file, $message = NULL) { + $message = isset($message) ? $message : format_string('File %file exists on the disk.', array('%file' => $file->getFileUri())); + $this->assertFalse(is_file($file->getFileUri()), $message); + } + + /** + * Asserts that a file does not exist in the database. + */ + function assertFileEntryNotExists($file, $message) { + $this->container->get('entity.manager')->getStorage('file')->resetCache(); + $message = isset($message) ? $message : format_string('File %file exists in database at the correct path.', array('%file' => $file->getFileUri())); + $this->assertFalse(File::load($file->id()), $message); + } + + /** + * Asserts that a file's status is set to permanent in the database. + */ + function assertFileIsPermanent(FileInterface $file, $message = NULL) { + $message = isset($message) ? $message : format_string('File %file is permanent.', array('%file' => $file->getFileUri())); + $this->assertTrue($file->isPermanent(), $message); + } + +} diff --git a/core/modules/file/src/Tests/FileFieldValidateTest.php b/core/modules/file/tests/src/Functional/FileFieldValidateTest.php similarity index 99% rename from core/modules/file/src/Tests/FileFieldValidateTest.php rename to core/modules/file/tests/src/Functional/FileFieldValidateTest.php index 9d43060..e1f4332 100644 --- a/core/modules/file/src/Tests/FileFieldValidateTest.php +++ b/core/modules/file/tests/src/Functional/FileFieldValidateTest.php @@ -1,6 +1,6 @@ resetCache(); + + // Determine which hooks were called. + $actual = array_keys(array_filter(file_test_get_all_calls())); + + // Determine if there were any expected that were not called. + $uncalled = array_diff($expected, $actual); + if (count($uncalled)) { + $this->assertTrue(FALSE, format_string('Expected hooks %expected to be called but %uncalled was not called.', array('%expected' => implode(', ', $expected), '%uncalled' => implode(', ', $uncalled)))); + } + else { + $this->assertTrue(TRUE, format_string('All the expected hooks were called: %expected', array('%expected' => empty($expected) ? '(none)' : implode(', ', $expected)))); + } + + // Determine if there were any unexpected calls. + $unexpected = array_diff($actual, $expected); + if (count($unexpected)) { + $this->assertTrue(FALSE, format_string('Unexpected hooks were called: %unexpected.', array('%unexpected' => empty($unexpected) ? '(none)' : implode(', ', $unexpected)))); + } + else { + $this->assertTrue(TRUE, 'No unexpected hooks were called.'); + } + } + + /** + * Assert that a hook_file_* hook was called a certain number of times. + * + * @param string $hook + * String with the hook name; for instance, 'load', 'save', 'insert', etc. + * @param int $expected_count + * Optional integer count. + * @param string|null $message + * Optional translated string message. + */ + function assertFileHookCalled($hook, $expected_count = 1, $message = NULL) { + $actual_count = count(file_test_get_calls($hook)); + + if (!isset($message)) { + if ($actual_count == $expected_count) { + $message = format_string('hook_file_@name was called correctly.', array('@name' => $hook)); + } + elseif ($expected_count == 0) { + $message = \Drupal::translation()->formatPlural($actual_count, 'hook_file_@name was not expected to be called but was actually called once.', 'hook_file_@name was not expected to be called but was actually called @count times.', array('@name' => $hook, '@count' => $actual_count)); + } + else { + $message = format_string('hook_file_@name was expected to be called %expected times but was called %actual times.', array('@name' => $hook, '%expected' => $expected_count, '%actual' => $actual_count)); + } + } + $this->assertEqual($actual_count, $expected_count, $message); + } + + /** + * Asserts that two files have the same values (except timestamp). + * + * @param \Drupal\file\FileInterface $before + * File object to compare. + * @param \Drupal\file\FileInterface $after + * File object to compare. + */ + function assertFileUnchanged(FileInterface $before, FileInterface $after) { + $this->assertEqual($before->id(), $after->id(), t('File id is the same: %file1 == %file2.', array('%file1' => $before->id(), '%file2' => $after->id())), 'File unchanged'); + $this->assertEqual($before->getOwner()->id(), $after->getOwner()->id(), t('File owner is the same: %file1 == %file2.', array('%file1' => $before->getOwner()->id(), '%file2' => $after->getOwner()->id())), 'File unchanged'); + $this->assertEqual($before->getFilename(), $after->getFilename(), t('File name is the same: %file1 == %file2.', array('%file1' => $before->getFilename(), '%file2' => $after->getFilename())), 'File unchanged'); + $this->assertEqual($before->getFileUri(), $after->getFileUri(), t('File path is the same: %file1 == %file2.', array('%file1' => $before->getFileUri(), '%file2' => $after->getFileUri())), 'File unchanged'); + $this->assertEqual($before->getMimeType(), $after->getMimeType(), t('File MIME type is the same: %file1 == %file2.', array('%file1' => $before->getMimeType(), '%file2' => $after->getMimeType())), 'File unchanged'); + $this->assertEqual($before->getSize(), $after->getSize(), t('File size is the same: %file1 == %file2.', array('%file1' => $before->getSize(), '%file2' => $after->getSize())), 'File unchanged'); + $this->assertEqual($before->isPermanent(), $after->isPermanent(), t('File status is the same: %file1 == %file2.', array('%file1' => $before->isPermanent(), '%file2' => $after->isPermanent())), 'File unchanged'); + } + + /** + * Asserts that two files are not the same by comparing the fid and filepath. + * + * @param \Drupal\file\FileInterface $file1 + * File object to compare. + * @param \Drupal\file\FileInterface $file2 + * File object to compare. + */ + function assertDifferentFile(FileInterface $file1, FileInterface $file2) { + $this->assertNotEqual($file1->id(), $file2->id(), t('Files have different ids: %file1 != %file2.', array('%file1' => $file1->id(), '%file2' => $file2->id())), 'Different file'); + $this->assertNotEqual($file1->getFileUri(), $file2->getFileUri(), t('Files have different paths: %file1 != %file2.', array('%file1' => $file1->getFileUri(), '%file2' => $file2->getFileUri())), 'Different file'); + } + + /** + * Asserts that two files are the same by comparing the fid and filepath. + * + * @param \Drupal\file\FileInterface $file1 + * File object to compare. + * @param \Drupal\file\FileInterface $file2 + * File object to compare. + */ + function assertSameFile(FileInterface $file1, FileInterface $file2) { + $this->assertEqual($file1->id(), $file2->id(), t('Files have the same ids: %file1 == %file2.', array('%file1' => $file1->id(), '%file2-fid' => $file2->id())), 'Same file'); + $this->assertEqual($file1->getFileUri(), $file2->getFileUri(), t('Files have the same path: %file1 == %file2.', array('%file1' => $file1->getFileUri(), '%file2' => $file2->getFileUri())), 'Same file'); + } + + /** + * Create a file and save it to the files table and assert that it occurs + * correctly. + * + * @param string $filepath + * Optional string specifying the file path. If none is provided then a + * randomly named file will be created in the site's files directory. + * @param string $contents + * Optional contents to save into the file. If a NULL value is provided an + * arbitrary string will be used. + * @param string $scheme + * Optional string indicating the stream scheme to use. Drupal core includes + * public, private, and temporary. The public wrapper is the default. + * @return \Drupal\file\FileInterface + * File entity. + */ + function createFile($filepath = NULL, $contents = NULL, $scheme = NULL) { + // Don't count hook invocations caused by creating the file. + \Drupal::state()->set('file_test.count_hook_invocations', FALSE); + $file = File::create([ + 'uri' => $this->createUri($filepath, $contents, $scheme), + 'uid' => 1, + ]); + $file->save(); + // Write the record directly rather than using the API so we don't invoke + // the hooks. + $this->assertTrue($file->id() > 0, 'The file was added to the database.', 'Create test file'); + + \Drupal::state()->set('file_test.count_hook_invocations', TRUE); + return $file; + } + + /** + * Creates a file and returns its URI. + * + * @param string $filepath + * Optional string specifying the file path. If none is provided then a + * randomly named file will be created in the site's files directory. + * @param string $contents + * Optional contents to save into the file. If a NULL value is provided an + * arbitrary string will be used. + * @param string $scheme + * Optional string indicating the stream scheme to use. Drupal core includes + * public, private, and temporary. The public wrapper is the default. + * + * @return string + * File URI. + */ + function createUri($filepath = NULL, $contents = NULL, $scheme = NULL) { + if (!isset($filepath)) { + // Prefix with non-latin characters to ensure that all file-related + // tests work with international filenames. + $filepath = 'Файл для тестирования ' . $this->randomMachineName(); + } + if (!isset($scheme)) { + $scheme = file_default_scheme(); + } + $filepath = $scheme . '://' . $filepath; + + if (!isset($contents)) { + $contents = "file_put_contents() doesn't seem to appreciate empty strings so let's put in some data."; + } + + file_put_contents($filepath, $contents); + $this->assertTrue(is_file($filepath), t('The test file exists on the disk.'), 'Create test file'); + return $filepath; + } + +} diff --git a/core/modules/file/src/Tests/FilePrivateTest.php b/core/modules/file/tests/src/Functional/FilePrivateTest.php similarity index 99% rename from core/modules/file/src/Tests/FilePrivateTest.php rename to core/modules/file/tests/src/Functional/FilePrivateTest.php index 2705ef2..e77d72f 100644 --- a/core/modules/file/src/Tests/FilePrivateTest.php +++ b/core/modules/file/tests/src/Functional/FilePrivateTest.php @@ -1,6 +1,6 @@ profile != 'standard') { + $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page')); + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + } + + $this->adminUser = $this->drupalCreateUser(array('access content', 'access administration pages', 'administer site configuration', 'administer content types', 'administer node fields', 'administer nodes', 'create article content', 'edit any article content', 'delete any article content', 'administer image styles', 'administer node display')); + $this->drupalLogin($this->adminUser); + } + + /** + * Create a new image field. + * + * @param string $name + * The name of the new field (all lowercase), exclude the "field_" prefix. + * @param string $type_name + * The node type that this field will be added to. + * @param array $storage_settings + * A list of field storage settings that will be added to the defaults. + * @param array $field_settings + * A list of instance settings that will be added to the instance defaults. + * @param array $widget_settings + * Widget settings to be added to the widget defaults. + * @param array $formatter_settings + * Formatter settings to be added to the formatter defaults. + * @param string $description + * A description for the field. + */ + function createImageField($name, $type_name, $storage_settings = array(), $field_settings = array(), $widget_settings = array(), $formatter_settings = array(), $description = '') { + FieldStorageConfig::create(array( + 'field_name' => $name, + 'entity_type' => 'node', + 'type' => 'image', + 'settings' => $storage_settings, + 'cardinality' => !empty($storage_settings['cardinality']) ? $storage_settings['cardinality'] : 1, + ))->save(); + + $field_config = FieldConfig::create([ + 'field_name' => $name, + 'label' => $name, + 'entity_type' => 'node', + 'bundle' => $type_name, + 'required' => !empty($field_settings['required']), + 'settings' => $field_settings, + 'description' => $description, + ]); + $field_config->save(); + + entity_get_form_display('node', $type_name, 'default') + ->setComponent($name, array( + 'type' => 'image_image', + 'settings' => $widget_settings, + )) + ->save(); + + entity_get_display('node', $type_name, 'default') + ->setComponent($name, array( + 'type' => 'image', + 'settings' => $formatter_settings, + )) + ->save(); + + return $field_config; + + } + + /** + * Preview an image in a node. + * + * @param \Drupal\Core\Image\ImageInterface $image + * A file object representing the image to upload. + * @param string $field_name + * Name of the image field the image should be attached to. + * @param string $type + * The type of node to create. + */ + function previewNodeImage($image, $field_name, $type) { + $edit = array( + 'title[0][value]' => $this->randomMachineName(), + ); + $edit['files[' . $field_name . '_0]'] = drupal_realpath($image->uri); + $this->drupalPostForm('node/add/' . $type, $edit, t('Preview')); + } + + /** + * Upload an image to a node. + * + * @param $image + * A file object representing the image to upload. + * @param $field_name + * Name of the image field the image should be attached to. + * @param $type + * The type of node to create. + * @param $alt + * The alt text for the image. Use if the field settings require alt text. + */ + function uploadNodeImage($image, $field_name, $type, $alt = '') { + $edit = array( + 'title[0][value]' => $this->randomMachineName(), + ); + $edit['files[' . $field_name . '_0]'] = drupal_realpath($image->uri); + $this->drupalPostForm('node/add/' . $type, $edit, t('Save and publish')); + if ($alt) { + // Add alt text. + $this->drupalPostForm(NULL, [$field_name . '[0][alt]' => $alt], t('Save and publish')); + } + + // Retrieve ID of the newly created node from the current URL. + $matches = array(); + preg_match('/node\/([0-9]+)/', $this->getUrl(), $matches); + return isset($matches[1]) ? $matches[1] : FALSE; + } + + /** + * Retrieves the fid of the last inserted file. + */ + protected function getLastFileId() { + return (int) db_query('SELECT MAX(fid) FROM {file_managed}')->fetchField(); + } + +} diff --git a/core/modules/image/src/Tests/ImageFieldWidgetTest.php b/core/modules/image/tests/src/Functional/ImageFieldWidgetTest.php similarity index 95% rename from core/modules/image/src/Tests/ImageFieldWidgetTest.php rename to core/modules/image/tests/src/Functional/ImageFieldWidgetTest.php index 52d9e4d..f8f9601 100644 --- a/core/modules/image/src/Tests/ImageFieldWidgetTest.php +++ b/core/modules/image/tests/src/Functional/ImageFieldWidgetTest.php @@ -1,6 +1,6 @@ createStub($entity_type_id); + $this->assertTrue($entity_id, 'Stub successfully created'); + if ($entity_id) { + $violations = $this->validateStub($entity_type_id, $entity_id); + if (!$this->assertIdentical(count($violations), 0, 'Stub is a valid entity')) { + foreach ($violations as $violation) { + $this->fail((string) $violation->getMessage()); + } + } + } + } + + /** + * Create a stub of the given entity type. + * + * @param string $entity_type_id + * The entity type we are stubbing. + * + * @return int + * ID of the created entity. + */ + protected function createStub($entity_type_id) { + // Create a dummy migration to pass to the destination plugin. + $definition = [ + 'migration_tags' => ['Stub test'], + 'source' => ['plugin' => 'empty'], + 'process' => [], + 'destination' => ['plugin' => 'entity:' . $entity_type_id], + ]; + $migration = \Drupal::service('plugin.manager.migration')->createStubMigration($definition); + $destination_plugin = $migration->getDestinationPlugin(TRUE); + $stub_row = new Row([], [], TRUE); + $destination_ids = $destination_plugin->import($stub_row); + return reset($destination_ids); + } + + /** + * Perform validation on a stub entity. + * + * @param string $entity_type_id + * The entity type we are stubbing. + * @param string $entity_id + * ID of the stubbed entity to validate. + * + * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface + * List of constraint violations identified. + */ + protected function validateStub($entity_type_id, $entity_id) { + $controller = \Drupal::entityManager()->getStorage($entity_type_id); + /** @var \Drupal\Core\Entity\ContentEntityInterface $stub_entity */ + $stub_entity = $controller->load($entity_id); + return $stub_entity->validate(); + } + +} diff --git a/core/modules/node/tests/src/Functional/AssertButtonsTrait.php b/core/modules/node/tests/src/Functional/AssertButtonsTrait.php new file mode 100644 index 0000000..3f3c87f --- /dev/null +++ b/core/modules/node/tests/src/Functional/AssertButtonsTrait.php @@ -0,0 +1,48 @@ +xpath('//input[@type="submit"][@value="Save"]'); + + // Verify that the number of buttons passed as parameters is + // available in the dropbutton widget. + if ($dropbutton) { + $i = 0; + $count = count($buttons); + + // Assert there is no save button. + $this->assertTrue(empty($save_button)); + + // Dropbutton elements. + $elements = $this->xpath('//div[@class="dropbutton-wrapper"]//input[@type="submit"]'); + $this->assertEqual($count, count($elements)); + foreach ($elements as $element) { + $value = isset($element['value']) ? (string) $element['value'] : ''; + $this->assertEqual($buttons[$i], $value); + $i++; + } + } + else { + // Assert there is a save button. + $this->assertTrue(!empty($save_button)); + $this->assertNoRaw('dropbutton-wrapper'); + } + } + +} diff --git a/core/modules/node/src/Tests/Migrate/d6/MigrateNodeRevisionTest.php b/core/modules/node/tests/src/Functional/Migrate/d6/MigrateNodeRevisionTest.php similarity index 97% rename from core/modules/node/src/Tests/Migrate/d6/MigrateNodeRevisionTest.php rename to core/modules/node/tests/src/Functional/Migrate/d6/MigrateNodeRevisionTest.php index a02ef3f..8c9ef63 100644 --- a/core/modules/node/src/Tests/Migrate/d6/MigrateNodeRevisionTest.php +++ b/core/modules/node/tests/src/Functional/Migrate/d6/MigrateNodeRevisionTest.php @@ -1,6 +1,6 @@ profile != 'standard') { + $this->drupalCreateContentType(array( + 'type' => 'page', + 'name' => 'Basic page', + 'display_submitted' => FALSE, + )); + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + } + $this->accessHandler = \Drupal::entityManager()->getAccessControlHandler('node'); + } + + /** + * Asserts that node access correctly grants or denies access. + * + * @param array $ops + * An associative array of the expected node access grants for the node + * and account, with each key as the name of an operation (e.g. 'view', + * 'delete') and each value a Boolean indicating whether access to that + * operation should be granted. + * @param \Drupal\node\NodeInterface $node + * The node object to check. + * @param \Drupal\Core\Session\AccountInterface $account + * The user account for which to check access. + */ + function assertNodeAccess(array $ops, NodeInterface $node, AccountInterface $account) { + foreach ($ops as $op => $result) { + $this->assertEqual($result, $this->accessHandler->access($node, $op, $account), $this->nodeAccessAssertMessage($op, $result, $node->language()->getId())); + } + } + + /** + * Asserts that node create access correctly grants or denies access. + * + * @param string $bundle + * The node bundle to check access to. + * @param bool $result + * Whether access should be granted or not. + * @param \Drupal\Core\Session\AccountInterface $account + * The user account for which to check access. + * @param string|null $langcode + * (optional) The language code indicating which translation of the node + * to check. If NULL, the untranslated (fallback) access is checked. + */ + function assertNodeCreateAccess($bundle, $result, AccountInterface $account, $langcode = NULL) { + $this->assertEqual($result, $this->accessHandler->createAccess($bundle, $account, array( + 'langcode' => $langcode, + )), $this->nodeAccessAssertMessage('create', $result, $langcode)); + } + + /** + * Constructs an assert message to display which node access was tested. + * + * @param string $operation + * The operation to check access for. + * @param bool $result + * Whether access should be granted or not. + * @param string|null $langcode + * (optional) The language code indicating which translation of the node + * to check. If NULL, the untranslated (fallback) access is checked. + * + * @return string + * An assert message string which contains information in plain English + * about the node access permission test that was performed. + */ + function nodeAccessAssertMessage($operation, $result, $langcode = NULL) { + return format_string( + 'Node access returns @result with operation %op, language code %langcode.', + array( + '@result' => $result ? 'true' : 'false', + '%op' => $operation, + '%langcode' => !empty($langcode) ? $langcode : 'empty' + ) + ); + } + +} diff --git a/core/modules/node/src/Tests/NodeTitleTest.php b/core/modules/node/tests/src/Functional/NodeTitleTest.php similarity index 98% rename from core/modules/node/src/Tests/NodeTitleTest.php rename to core/modules/node/tests/src/Functional/NodeTitleTest.php index 59ee021..38d5752 100644 --- a/core/modules/node/src/Tests/NodeTitleTest.php +++ b/core/modules/node/tests/src/Functional/NodeTitleTest.php @@ -1,6 +1,6 @@ profile != 'standard') { + $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page')); + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + } + } + +} diff --git a/core/modules/rdf/src/Tests/CommentAttributesTest.php b/core/modules/rdf/tests/src/Functional/CommentAttributesTest.php similarity index 99% rename from core/modules/rdf/src/Tests/CommentAttributesTest.php rename to core/modules/rdf/tests/src/Functional/CommentAttributesTest.php index 5febcb9..92e6dda 100644 --- a/core/modules/rdf/src/Tests/CommentAttributesTest.php +++ b/core/modules/rdf/tests/src/Functional/CommentAttributesTest.php @@ -1,6 +1,6 @@ profile != 'standard') { + $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page')); + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + } + } + + /** + * Simulates submission of a form using GET instead of POST. + * + * Forms that use the GET method cannot be submitted with + * WebTestBase::drupalPostForm(), which explicitly uses POST to submit the + * form. So this method finds the form, verifies that it has input fields and + * a submit button matching the inputs to this method, and then calls + * WebTestBase::drupalGet() to simulate the form submission to the 'action' + * URL of the form (if set, or the current URL if not). + * + * See WebTestBase::drupalPostForm() for more detailed documentation of the + * function parameters. + * + * @param string $path + * Location of the form to be submitted: either a Drupal path, absolute + * path, or NULL to use the current page. + * @param array $edit + * Form field data to submit. Unlike drupalPostForm(), this does not support + * file uploads. + * @param string $submit + * Value of the submit button to submit clicking. Unlike drupalPostForm(), + * this does not support AJAX. + * @param string $form_html_id + * (optional) HTML ID of the form, to disambiguate. + */ + protected function submitGetForm($path, $edit, $submit, $form_html_id = NULL) { + if (isset($path)) { + $this->drupalGet($path); + } + + if ($this->parse()) { + // Iterate over forms to find one that matches $edit and $submit. + $edit_save = $edit; + $xpath = '//form'; + if (!empty($form_html_id)) { + $xpath .= "[@id='" . $form_html_id . "']"; + } + $forms = $this->xpath($xpath); + foreach ($forms as $form) { + // Try to set the fields of this form as specified in $edit. + $edit = $edit_save; + $post = array(); + $upload = array(); + $submit_matches = $this->handleForm($post, $edit, $upload, $submit, $form); + if (!$edit && $submit_matches) { + // Everything matched, so "submit" the form. + $action = isset($form['action']) ? $this->getAbsoluteUrl((string) $form['action']) : NULL; + $this->drupalGet($action, array('query' => $post)); + return; + } + } + + // We have not found a form which contained all fields of $edit and + // the submit button. + foreach ($edit as $name => $value) { + $this->fail(SafeMarkup::format('Failed to set field @name to @value', array('@name' => $name, '@value' => $value))); + } + $this->assertTrue($submit_matches, format_string('Found the @submit button', array('@submit' => $submit))); + $this->fail(format_string('Found the requested form fields at @path', array('@path' => $path))); + } + } + +} diff --git a/core/modules/search/src/Tests/SearchTokenizerTest.php b/core/modules/search/tests/src/Functional/SearchTokenizerTest.php similarity index 99% rename from core/modules/search/src/Tests/SearchTokenizerTest.php rename to core/modules/search/tests/src/Functional/SearchTokenizerTest.php index f8b3ccb..e25bfc4 100644 --- a/core/modules/search/src/Tests/SearchTokenizerTest.php +++ b/core/modules/search/tests/src/Functional/SearchTokenizerTest.php @@ -1,6 +1,6 @@ installEntitySchema('entity_test_mulrev'); + $this->installEntitySchema('user'); + $this->installConfig(array('field')); + \Drupal::service('router.builder')->rebuild(); + \Drupal::moduleHandler()->invoke('rest', 'install'); + + // Auto-create a field for testing. + FieldStorageConfig::create(array( + 'entity_type' => 'entity_test_mulrev', + 'field_name' => 'field_test_text', + 'type' => 'text', + 'cardinality' => 1, + 'translatable' => FALSE, + ))->save(); + FieldConfig::create(array( + 'entity_type' => 'entity_test_mulrev', + 'field_name' => 'field_test_text', + 'bundle' => 'entity_test_mulrev', + 'label' => 'Test text-field', + 'widget' => array( + 'type' => 'text_textfield', + 'weight' => 0, + ), + ))->save(); + } + +} diff --git a/core/modules/serialization/src/Tests/RegisterSerializationClassesCompilerPassTest.php b/core/modules/serialization/tests/src/Functional/RegisterSerializationClassesCompilerPassTest.php similarity index 97% rename from core/modules/serialization/src/Tests/RegisterSerializationClassesCompilerPassTest.php rename to core/modules/serialization/tests/src/Functional/RegisterSerializationClassesCompilerPassTest.php index 6406bbb..5f18927 100644 --- a/core/modules/serialization/src/Tests/RegisterSerializationClassesCompilerPassTest.php +++ b/core/modules/serialization/tests/src/Functional/RegisterSerializationClassesCompilerPassTest.php @@ -1,6 +1,6 @@ profile != 'standard') { + // Create Basic page and Article node types. + $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page')); + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + + // Populate the default shortcut set. + $shortcut = Shortcut::create(array( + 'shortcut_set' => 'default', + 'title' => t('Add content'), + 'weight' => -20, + 'link' => array( + 'uri' => 'internal:/node/add', + ), + )); + $shortcut->save(); + + $shortcut = Shortcut::create(array( + 'shortcut_set' => 'default', + 'title' => t('All content'), + 'weight' => -19, + 'link' => array( + 'uri' => 'internal:/admin/content', + ), + )); + $shortcut->save(); + } + + // Create users. + $this->adminUser = $this->drupalCreateUser(array('access toolbar', 'administer shortcuts', 'view the administration theme', 'create article content', 'create page content', 'access content overview', 'administer users', 'link to any page', 'edit any article content')); + $this->shortcutUser = $this->drupalCreateUser(array('customize shortcut links', 'switch shortcut sets', 'access shortcuts', 'access content')); + + // Create a node. + $this->node = $this->drupalCreateNode(array('type' => 'article')); + + // Log in as admin and grab the default shortcut set. + $this->drupalLogin($this->adminUser); + $this->set = ShortcutSet::load('default'); + \Drupal::entityManager()->getStorage('shortcut_set')->assignUser($this->set, $this->adminUser); + } + + /** + * Creates a generic shortcut set. + */ + function generateShortcutSet($label = '', $id = NULL) { + $set = ShortcutSet::create(array( + 'id' => isset($id) ? $id : strtolower($this->randomMachineName()), + 'label' => empty($label) ? $this->randomString() : $label, + )); + $set->save(); + return $set; + } + + /** + * Extracts information from shortcut set links. + * + * @param \Drupal\shortcut\ShortcutSetInterface $set + * The shortcut set object to extract information from. + * @param string $key + * The array key indicating what information to extract from each link: + * - 'title': Extract shortcut titles. + * - 'link': Extract shortcut paths. + * - 'id': Extract the shortcut ID. + * + * @return array + * Array of the requested information from each link. + */ + function getShortcutInformation(ShortcutSetInterface $set, $key) { + $info = array(); + \Drupal::entityManager()->getStorage('shortcut')->resetCache(); + foreach ($set->getShortcuts() as $shortcut) { + if ($key == 'link') { + $info[] = $shortcut->link->uri; + } + else { + $info[] = $shortcut->{$key}->value; + } + } + return $info; + } + +} diff --git a/core/modules/simpletest/src/Tests/BrokenSetUpTest.php b/core/modules/simpletest/tests/src/Functional/BrokenSetUpTest.php similarity index 96% rename from core/modules/simpletest/src/Tests/BrokenSetUpTest.php rename to core/modules/simpletest/tests/src/Functional/BrokenSetUpTest.php index e988f4d..8a8cdf1 100644 --- a/core/modules/simpletest/src/Tests/BrokenSetUpTest.php +++ b/core/modules/simpletest/tests/src/Functional/BrokenSetUpTest.php @@ -1,8 +1,8 @@ profile != 'standard') { + $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page')); + } + + // Create user. + $this->blockingUser = $this->drupalCreateUser(array( + 'access administration pages', + 'access site reports', + 'ban IP addresses', + 'administer blocks', + 'administer statistics', + 'administer users', + )); + $this->drupalLogin($this->blockingUser); + + // Enable logging. + $this->config('statistics.settings') + ->set('count_content_views', 1) + ->save(); + } + +} diff --git a/core/modules/statistics/src/Tests/StatisticsTokenReplaceTest.php b/core/modules/statistics/tests/src/Functional/StatisticsTokenReplaceTest.php similarity index 97% rename from core/modules/statistics/src/Tests/StatisticsTokenReplaceTest.php rename to core/modules/statistics/tests/src/Functional/StatisticsTokenReplaceTest.php index b7d22b8..d586ea6 100644 --- a/core/modules/statistics/src/Tests/StatisticsTokenReplaceTest.php +++ b/core/modules/statistics/tests/src/Functional/StatisticsTokenReplaceTest.php @@ -1,6 +1,6 @@ assertTrue($found, $message); + } + +} diff --git a/core/modules/system/src/Tests/Bootstrap/DrupalSetMessageTest.php b/core/modules/system/tests/src/Functional/Bootstrap/DrupalSetMessageTest.php similarity index 90% rename from core/modules/system/src/Tests/Bootstrap/DrupalSetMessageTest.php rename to core/modules/system/tests/src/Functional/Bootstrap/DrupalSetMessageTest.php index 29ca02f..1b0f31a 100644 --- a/core/modules/system/src/Tests/Bootstrap/DrupalSetMessageTest.php +++ b/core/modules/system/tests/src/Functional/Bootstrap/DrupalSetMessageTest.php @@ -1,15 +1,15 @@ config('system.performance'); + $config->set('cache.page.max_age', 300); + $config->save(); + } + + /** + * Gets a specific header value as array. + * + * @param string $header_name + * The header name. + * + * @return string[] + * The header value, potentially exploded by spaces. + */ + protected function getCacheHeaderValues($header_name) { + $header_value = $this->drupalGetHeader($header_name); + if (empty($header_value)) { + return []; + } + else { + return explode(' ', $header_value); + } + } + + /** + * Asserts whether an expected cache context was present in the last response. + * + * @param string $expected_cache_context + * The expected cache context. + */ + protected function assertCacheContext($expected_cache_context) { + $cache_contexts = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Contexts')); + $this->assertTrue(in_array($expected_cache_context, $cache_contexts), "'" . $expected_cache_context . "' is present in the X-Drupal-Cache-Contexts header."); + } + + /** + * Asserts that a cache context was not present in the last response. + * + * @param string $not_expected_cache_context + * The expected cache context. + */ + protected function assertNoCacheContext($not_expected_cache_context) { + $cache_contexts = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Contexts')); + $this->assertFalse(in_array($not_expected_cache_context, $cache_contexts), "'" . $not_expected_cache_context . "' is not present in the X-Drupal-Cache-Contexts header."); + } + + /** + * Asserts page cache miss, then hit for the given URL; checks cache headers. + * + * @param \Drupal\Core\Url $url + * The URL to test. + * @param string[] $expected_contexts + * The expected cache contexts for the given URL. + * @param string[] $expected_tags + * The expected cache tags for the given URL. + */ + protected function assertPageCacheContextsAndTags(Url $url, array $expected_contexts, array $expected_tags) { + $absolute_url = $url->setAbsolute()->toString(); + sort($expected_contexts); + sort($expected_tags); + + // Assert cache miss + expected cache contexts + tags. + $this->drupalGet($absolute_url); + $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS'); + $this->assertCacheTags($expected_tags); + $this->assertCacheContexts($expected_contexts); + + // Assert cache hit + expected cache contexts + tags. + $this->drupalGet($absolute_url); + $this->assertCacheTags($expected_tags); + $this->assertCacheContexts($expected_contexts); + + // Assert page cache item + expected cache tags. + $cid_parts = array($url->setAbsolute()->toString(), 'html'); + $cid = implode(':', $cid_parts); + $cache_entry = \Drupal::cache('render')->get($cid); + sort($cache_entry->tags); + $this->assertEqual($cache_entry->tags, $expected_tags); + $this->debugCacheTags($cache_entry->tags, $expected_tags); + } + + /** + * Provides debug information for cache tags. + * + * @param string[] $actual_tags + * The actual cache tags. + * @param string[] $expected_tags + * The expected cache tags. + */ + protected function debugCacheTags(array $actual_tags, array $expected_tags) { + if ($actual_tags !== $expected_tags) { + debug('Unwanted cache tags in response: ' . implode(',', array_diff($actual_tags, $expected_tags))); + debug('Missing cache tags in response: ' . implode(',', array_diff($expected_tags, $actual_tags))); + } + } + + /** + * Ensures that some cache tags are present in the current response. + * + * @param string[] $expected_tags + * The expected tags. + * @param bool $include_default_tags + * (optional) Whether the default cache tags should be included. + */ + protected function assertCacheTags(array $expected_tags, $include_default_tags = TRUE) { + // The anonymous role cache tag is only added if the user is anonymous. + if ($include_default_tags && \Drupal::currentUser()->isAnonymous()) { + $expected_tags = Cache::mergeTags($expected_tags, ['config:user.role.anonymous']); + } + $actual_tags = $this->getCacheHeaderValues('X-Drupal-Cache-Tags'); + sort($expected_tags); + sort($actual_tags); + $this->assertIdentical($actual_tags, $expected_tags); + $this->debugCacheTags($actual_tags, $expected_tags); + } + + /** + * Ensures that some cache contexts are present in the current response. + * + * @param string[] $expected_contexts + * The expected cache contexts. + * @param string $message + * (optional) A verbose message to output. + * @param bool $include_default_contexts + * (optional) Whether the default contexts should automatically be included. + * + * @return + * TRUE if the assertion succeeded, FALSE otherwise. + */ + protected function assertCacheContexts(array $expected_contexts, $message = NULL, $include_default_contexts = TRUE) { + if ($include_default_contexts) { + $default_contexts = ['languages:language_interface', 'theme']; + // Add the user.permission context to the list of default contexts except + // when user is already there. + if (!in_array('user', $expected_contexts)) { + $default_contexts[] = 'user.permissions'; + } + $expected_contexts = Cache::mergeContexts($expected_contexts, $default_contexts); + } + + $actual_contexts = $this->getCacheHeaderValues('X-Drupal-Cache-Contexts'); + sort($expected_contexts); + sort($actual_contexts); + $return = $this->assertIdentical($actual_contexts, $expected_contexts, $message); + if (!$return) { + debug('Unwanted cache contexts in response: ' . implode(',', array_diff($actual_contexts, $expected_contexts))); + debug('Missing cache contexts in response: ' . implode(',', array_diff($expected_contexts, $actual_contexts))); + } + return $return; + } + + /** + * Asserts the max age header. + * + * @param int $max_age + */ + protected function assertCacheMaxAge($max_age) { + $cache_control_header = $this->drupalGetHeader('Cache-Control'); + if (strpos($cache_control_header, 'max-age:' . $max_age) === FALSE) { + debug('Expected max-age:' . $max_age . '; Response max-age:' . $cache_control_header); + } + $this->assertTrue(strpos($cache_control_header, 'max-age:' . $max_age)); + } + +} diff --git a/core/modules/system/tests/src/Functional/Cache/CacheTestBase.php b/core/modules/system/tests/src/Functional/Cache/CacheTestBase.php new file mode 100644 index 0000000..fb552fd --- /dev/null +++ b/core/modules/system/tests/src/Functional/Cache/CacheTestBase.php @@ -0,0 +1,86 @@ +defaultBin; + } + + $cached = \Drupal::cache($bin)->get($cid); + + return isset($cached->data) && $cached->data == $var; + } + + /** + * Asserts that a cache entry exists. + * + * @param $message + * Message to display. + * @param $var + * The variable the cache should contain. + * @param $cid + * The cache id. + * @param $bin + * The bin the cache item was stored in. + */ + protected function assertCacheExists($message, $var = NULL, $cid = NULL, $bin = NULL) { + if ($bin == NULL) { + $bin = $this->defaultBin; + } + if ($cid == NULL) { + $cid = $this->defaultCid; + } + if ($var == NULL) { + $var = $this->defaultValue; + } + + $this->assertTrue($this->checkCacheExists($cid, $var, $bin), $message); + } + + /** + * Asserts that a cache entry has been removed. + * + * @param $message + * Message to display. + * @param $cid + * The cache id. + * @param $bin + * The bin the cache item was stored in. + */ + function assertCacheRemoved($message, $cid = NULL, $bin = NULL) { + if ($bin == NULL) { + $bin = $this->defaultBin; + } + if ($cid == NULL) { + $cid = $this->defaultCid; + } + + $cached = \Drupal::cache($bin)->get($cid); + $this->assertFalse($cached, $message); + } + +} diff --git a/core/modules/system/src/Tests/Cache/ClearTest.php b/core/modules/system/tests/src/Functional/Cache/ClearTest.php similarity index 95% rename from core/modules/system/src/Tests/Cache/ClearTest.php rename to core/modules/system/tests/src/Functional/Cache/ClearTest.php index 3a3fe90..4b33987 100644 --- a/core/modules/system/src/Tests/Cache/ClearTest.php +++ b/core/modules/system/tests/src/Functional/Cache/ClearTest.php @@ -1,6 +1,6 @@ testBin)) { + $this->testBin = 'page'; + } + return $this->testBin; + } + + /** + * Creates a cache backend to test. + * + * Override this method to test a CacheBackend. + * + * @param string $bin + * Bin name to use for this backend instance. + * + * @return \Drupal\Core\Cache\CacheBackendInterface + * Cache backend to test. + */ + protected abstract function createCacheBackend($bin); + + /** + * Allows specific implementation to change the environment before a test run. + */ + public function setUpCacheBackend() { + } + + /** + * Allows alteration of environment after a test run but before tear down. + * + * Used before the real tear down because the tear down will change things + * such as the database prefix. + */ + public function tearDownCacheBackend() { + } + + /** + * Gets a backend to test; this will get a shared instance set in the object. + * + * @return \Drupal\Core\Cache\CacheBackendInterface + * Cache backend to test. + */ + protected function getCacheBackend($bin = NULL) { + if (!isset($bin)) { + $bin = $this->getTestBin(); + } + if (!isset($this->cachebackends[$bin])) { + $this->cachebackends[$bin] = $this->createCacheBackend($bin); + // Ensure the backend is empty. + $this->cachebackends[$bin]->deleteAll(); + } + return $this->cachebackends[$bin]; + } + + protected function setUp() { + $this->cachebackends = array(); + $this->defaultValue = $this->randomMachineName(10); + + parent::setUp(); + + $this->setUpCacheBackend(); + } + + protected function tearDown() { + // Destruct the registered backend, each test will get a fresh instance, + // properly emptying it here ensure that on persistent data backends they + // will come up empty the next test. + foreach ($this->cachebackends as $bin => $cachebackend) { + $this->cachebackends[$bin]->deleteAll(); + } + unset($this->cachebackends); + + $this->tearDownCacheBackend(); + + parent::tearDown(); + } + + /** + * Tests the get and set methods of Drupal\Core\Cache\CacheBackendInterface. + */ + public function testSetGet() { + $backend = $this->getCacheBackend(); + + $this->assertIdentical(FALSE, $backend->get('test1'), "Backend does not contain data for cache id test1."); + $with_backslash = array('foo' => '\Drupal\foo\Bar'); + $backend->set('test1', $with_backslash); + $cached = $backend->get('test1'); + $this->assert(is_object($cached), "Backend returned an object for cache id test1."); + $this->assertIdentical($with_backslash, $cached->data); + $this->assertTrue($cached->valid, 'Item is marked as valid.'); + // We need to round because microtime may be rounded up in the backend. + $this->assertTrue($cached->created >= REQUEST_TIME && $cached->created <= round(microtime(TRUE), 3), 'Created time is correct.'); + $this->assertEqual($cached->expire, Cache::PERMANENT, 'Expire time is correct.'); + + $this->assertIdentical(FALSE, $backend->get('test2'), "Backend does not contain data for cache id test2."); + $backend->set('test2', array('value' => 3), REQUEST_TIME + 3); + $cached = $backend->get('test2'); + $this->assert(is_object($cached), "Backend returned an object for cache id test2."); + $this->assertIdentical(array('value' => 3), $cached->data); + $this->assertTrue($cached->valid, 'Item is marked as valid.'); + $this->assertTrue($cached->created >= REQUEST_TIME && $cached->created <= round(microtime(TRUE), 3), 'Created time is correct.'); + $this->assertEqual($cached->expire, REQUEST_TIME + 3, 'Expire time is correct.'); + + $backend->set('test3', 'foobar', REQUEST_TIME - 3); + $this->assertFalse($backend->get('test3'), 'Invalid item not returned.'); + $cached = $backend->get('test3', TRUE); + $this->assert(is_object($cached), 'Backend returned an object for cache id test3.'); + $this->assertFalse($cached->valid, 'Item is marked as valid.'); + $this->assertTrue($cached->created >= REQUEST_TIME && $cached->created <= round(microtime(TRUE), 3), 'Created time is correct.'); + $this->assertEqual($cached->expire, REQUEST_TIME - 3, 'Expire time is correct.'); + + $this->assertIdentical(FALSE, $backend->get('test4'), "Backend does not contain data for cache id test4."); + $with_eof = array('foo' => "\nEOF\ndata"); + $backend->set('test4', $with_eof); + $cached = $backend->get('test4'); + $this->assert(is_object($cached), "Backend returned an object for cache id test4."); + $this->assertIdentical($with_eof, $cached->data); + $this->assertTrue($cached->valid, 'Item is marked as valid.'); + $this->assertTrue($cached->created >= REQUEST_TIME && $cached->created <= round(microtime(TRUE), 3), 'Created time is correct.'); + $this->assertEqual($cached->expire, Cache::PERMANENT, 'Expire time is correct.'); + + $this->assertIdentical(FALSE, $backend->get('test5'), "Backend does not contain data for cache id test5."); + $with_eof_and_semicolon = array('foo' => "\nEOF;\ndata"); + $backend->set('test5', $with_eof_and_semicolon); + $cached = $backend->get('test5'); + $this->assert(is_object($cached), "Backend returned an object for cache id test5."); + $this->assertIdentical($with_eof_and_semicolon, $cached->data); + $this->assertTrue($cached->valid, 'Item is marked as valid.'); + $this->assertTrue($cached->created >= REQUEST_TIME && $cached->created <= round(microtime(TRUE), 3), 'Created time is correct.'); + $this->assertEqual($cached->expire, Cache::PERMANENT, 'Expire time is correct.'); + + $with_variable = array('foo' => '$bar'); + $backend->set('test6', $with_variable); + $cached = $backend->get('test6'); + $this->assert(is_object($cached), "Backend returned an object for cache id test6."); + $this->assertIdentical($with_variable, $cached->data); + + // Make sure that a cached object is not affected by changing the original. + $data = new \stdClass(); + $data->value = 1; + $data->obj = new \stdClass(); + $data->obj->value = 2; + $backend->set('test7', $data); + $expected_data = clone $data; + // Add a property to the original. It should not appear in the cached data. + $data->this_should_not_be_in_the_cache = TRUE; + $cached = $backend->get('test7'); + $this->assert(is_object($cached), "Backend returned an object for cache id test7."); + $this->assertEqual($expected_data, $cached->data); + $this->assertFalse(isset($cached->data->this_should_not_be_in_the_cache)); + // Add a property to the cache data. It should not appear when we fetch + // the data from cache again. + $cached->data->this_should_not_be_in_the_cache = TRUE; + $fresh_cached = $backend->get('test7'); + $this->assertFalse(isset($fresh_cached->data->this_should_not_be_in_the_cache)); + + // Check with a long key. + $cid = str_repeat('a', 300); + $backend->set($cid, 'test'); + $this->assertEqual('test', $backend->get($cid)->data); + + // Check that the cache key is case sensitive. + $backend->set('TEST8', 'value'); + $this->assertEqual('value', $backend->get('TEST8')->data); + $this->assertFalse($backend->get('test8')); + + // Calling ::set() with invalid cache tags. This should fail an assertion. + try { + $backend->set('assertion_test', 'value', Cache::PERMANENT, ['node' => [3, 5, 7]]); + $this->fail('::set() was called with invalid cache tags, runtime assertion did not fail.'); + } + catch (\AssertionError $e) { + $this->pass('::set() was called with invalid cache tags, runtime assertion failed.'); + } + } + + /** + * Tests Drupal\Core\Cache\CacheBackendInterface::delete(). + */ + public function testDelete() { + $backend = $this->getCacheBackend(); + + $this->assertIdentical(FALSE, $backend->get('test1'), "Backend does not contain data for cache id test1."); + $backend->set('test1', 7); + $this->assert(is_object($backend->get('test1')), "Backend returned an object for cache id test1."); + + $this->assertIdentical(FALSE, $backend->get('test2'), "Backend does not contain data for cache id test2."); + $backend->set('test2', 3); + $this->assert(is_object($backend->get('test2')), "Backend returned an object for cache id %cid."); + + $backend->delete('test1'); + $this->assertIdentical(FALSE, $backend->get('test1'), "Backend does not contain data for cache id test1 after deletion."); + + $this->assert(is_object($backend->get('test2')), "Backend still has an object for cache id test2."); + + $backend->delete('test2'); + $this->assertIdentical(FALSE, $backend->get('test2'), "Backend does not contain data for cache id test2 after deletion."); + + $long_cid = str_repeat('a', 300); + $backend->set($long_cid, 'test'); + $backend->delete($long_cid); + $this->assertIdentical(FALSE, $backend->get($long_cid), "Backend does not contain data for long cache id after deletion."); + } + + /** + * Tests data type preservation. + */ + public function testValueTypeIsKept() { + $backend = $this->getCacheBackend(); + + $variables = array( + 'test1' => 1, + 'test2' => '0', + 'test3' => '', + 'test4' => 12.64, + 'test5' => FALSE, + 'test6' => array(1, 2, 3), + ); + + // Create cache entries. + foreach ($variables as $cid => $data) { + $backend->set($cid, $data); + } + + // Retrieve and test cache objects. + foreach ($variables as $cid => $value) { + $object = $backend->get($cid); + $this->assert(is_object($object), sprintf("Backend returned an object for cache id %s.", $cid)); + $this->assertIdentical($value, $object->data, sprintf("Data of cached id %s kept is identical in type and value", $cid)); + } + } + + /** + * Tests Drupal\Core\Cache\CacheBackendInterface::getMultiple(). + */ + public function testGetMultiple() { + $backend = $this->getCacheBackend(); + + // Set numerous testing keys. + $long_cid = str_repeat('a', 300); + $backend->set('test1', 1); + $backend->set('test2', 3); + $backend->set('test3', 5); + $backend->set('test4', 7); + $backend->set('test5', 11); + $backend->set('test6', 13); + $backend->set('test7', 17); + $backend->set($long_cid, 300); + + // Mismatch order for harder testing. + $reference = array( + 'test3', + 'test7', + 'test21', // Cid does not exist. + 'test6', + 'test19', // Cid does not exist until added before second getMultiple(). + 'test2', + ); + + $cids = $reference; + $ret = $backend->getMultiple($cids); + // Test return - ensure it contains existing cache ids. + $this->assert(isset($ret['test2']), "Existing cache id test2 is set."); + $this->assert(isset($ret['test3']), "Existing cache id test3 is set."); + $this->assert(isset($ret['test6']), "Existing cache id test6 is set."); + $this->assert(isset($ret['test7']), "Existing cache id test7 is set."); + // Test return - ensure that objects has expected properties. + $this->assertTrue($ret['test2']->valid, 'Item is marked as valid.'); + $this->assertTrue($ret['test2']->created >= REQUEST_TIME && $ret['test2']->created <= round(microtime(TRUE), 3), 'Created time is correct.'); + $this->assertEqual($ret['test2']->expire, Cache::PERMANENT, 'Expire time is correct.'); + // Test return - ensure it does not contain nonexistent cache ids. + $this->assertFalse(isset($ret['test19']), "Nonexistent cache id test19 is not set."); + $this->assertFalse(isset($ret['test21']), "Nonexistent cache id test21 is not set."); + // Test values. + $this->assertIdentical($ret['test2']->data, 3, "Existing cache id test2 has the correct value."); + $this->assertIdentical($ret['test3']->data, 5, "Existing cache id test3 has the correct value."); + $this->assertIdentical($ret['test6']->data, 13, "Existing cache id test6 has the correct value."); + $this->assertIdentical($ret['test7']->data, 17, "Existing cache id test7 has the correct value."); + // Test $cids array - ensure it contains cache id's that do not exist. + $this->assert(in_array('test19', $cids), "Nonexistent cache id test19 is in cids array."); + $this->assert(in_array('test21', $cids), "Nonexistent cache id test21 is in cids array."); + // Test $cids array - ensure it does not contain cache id's that exist. + $this->assertFalse(in_array('test2', $cids), "Existing cache id test2 is not in cids array."); + $this->assertFalse(in_array('test3', $cids), "Existing cache id test3 is not in cids array."); + $this->assertFalse(in_array('test6', $cids), "Existing cache id test6 is not in cids array."); + $this->assertFalse(in_array('test7', $cids), "Existing cache id test7 is not in cids array."); + + // Test a second time after deleting and setting new keys which ensures that + // if the backend uses statics it does not cause unexpected results. + $backend->delete('test3'); + $backend->delete('test6'); + $backend->set('test19', 57); + + $cids = $reference; + $ret = $backend->getMultiple($cids); + // Test return - ensure it contains existing cache ids. + $this->assert(isset($ret['test2']), "Existing cache id test2 is set"); + $this->assert(isset($ret['test7']), "Existing cache id test7 is set"); + $this->assert(isset($ret['test19']), "Added cache id test19 is set"); + // Test return - ensure it does not contain nonexistent cache ids. + $this->assertFalse(isset($ret['test3']), "Deleted cache id test3 is not set"); + $this->assertFalse(isset($ret['test6']), "Deleted cache id test6 is not set"); + $this->assertFalse(isset($ret['test21']), "Nonexistent cache id test21 is not set"); + // Test values. + $this->assertIdentical($ret['test2']->data, 3, "Existing cache id test2 has the correct value."); + $this->assertIdentical($ret['test7']->data, 17, "Existing cache id test7 has the correct value."); + $this->assertIdentical($ret['test19']->data, 57, "Added cache id test19 has the correct value."); + // Test $cids array - ensure it contains cache id's that do not exist. + $this->assert(in_array('test3', $cids), "Deleted cache id test3 is in cids array."); + $this->assert(in_array('test6', $cids), "Deleted cache id test6 is in cids array."); + $this->assert(in_array('test21', $cids), "Nonexistent cache id test21 is in cids array."); + // Test $cids array - ensure it does not contain cache id's that exist. + $this->assertFalse(in_array('test2', $cids), "Existing cache id test2 is not in cids array."); + $this->assertFalse(in_array('test7', $cids), "Existing cache id test7 is not in cids array."); + $this->assertFalse(in_array('test19', $cids), "Added cache id test19 is not in cids array."); + + // Test with a long $cid and non-numeric array key. + $cids = array('key:key' => $long_cid); + $return = $backend->getMultiple($cids); + $this->assertEqual(300, $return[$long_cid]->data); + $this->assertTrue(empty($cids)); + } + + /** + * Tests \Drupal\Core\Cache\CacheBackendInterface::setMultiple(). + */ + public function testSetMultiple() { + $backend = $this->getCacheBackend(); + + $future_expiration = REQUEST_TIME + 100; + + // Set multiple testing keys. + $backend->set('cid_1', 'Some other value'); + $items = array( + 'cid_1' => array('data' => 1), + 'cid_2' => array('data' => 2), + 'cid_3' => array('data' => array(1, 2)), + 'cid_4' => array('data' => 1, 'expire' => $future_expiration), + 'cid_5' => array('data' => 1, 'tags' => array('test:a', 'test:b')), + ); + $backend->setMultiple($items); + $cids = array_keys($items); + $cached = $backend->getMultiple($cids); + + $this->assertEqual($cached['cid_1']->data, $items['cid_1']['data'], 'Over-written cache item set correctly.'); + $this->assertTrue($cached['cid_1']->valid, 'Item is marked as valid.'); + $this->assertTrue($cached['cid_1']->created >= REQUEST_TIME && $cached['cid_1']->created <= round(microtime(TRUE), 3), 'Created time is correct.'); + $this->assertEqual($cached['cid_1']->expire, CacheBackendInterface::CACHE_PERMANENT, 'Cache expiration defaults to permanent.'); + + $this->assertEqual($cached['cid_2']->data, $items['cid_2']['data'], 'New cache item set correctly.'); + $this->assertEqual($cached['cid_2']->expire, CacheBackendInterface::CACHE_PERMANENT, 'Cache expiration defaults to permanent.'); + + $this->assertEqual($cached['cid_3']->data, $items['cid_3']['data'], 'New cache item with serialized data set correctly.'); + $this->assertEqual($cached['cid_3']->expire, CacheBackendInterface::CACHE_PERMANENT, 'Cache expiration defaults to permanent.'); + + $this->assertEqual($cached['cid_4']->data, $items['cid_4']['data'], 'New cache item set correctly.'); + $this->assertEqual($cached['cid_4']->expire, $future_expiration, 'Cache expiration has been correctly set.'); + + $this->assertEqual($cached['cid_5']->data, $items['cid_5']['data'], 'New cache item set correctly.'); + + // Calling ::setMultiple() with invalid cache tags. This should fail an + // assertion. + try { + $items = [ + 'exception_test_1' => array('data' => 1, 'tags' => []), + 'exception_test_2' => array('data' => 2, 'tags' => ['valid']), + 'exception_test_3' => array('data' => 3, 'tags' => ['node' => [3, 5, 7]]), + ]; + $backend->setMultiple($items); + $this->fail('::setMultiple() was called with invalid cache tags, runtime assertion did not fail.'); + } + catch (\AssertionError $e) { + $this->pass('::setMultiple() was called with invalid cache tags, runtime assertion failed.'); + } + } + + /** + * Test Drupal\Core\Cache\CacheBackendInterface::delete() and + * Drupal\Core\Cache\CacheBackendInterface::deleteMultiple(). + */ + public function testDeleteMultiple() { + $backend = $this->getCacheBackend(); + + // Set numerous testing keys. + $backend->set('test1', 1); + $backend->set('test2', 3); + $backend->set('test3', 5); + $backend->set('test4', 7); + $backend->set('test5', 11); + $backend->set('test6', 13); + $backend->set('test7', 17); + + $backend->delete('test1'); + $backend->delete('test23'); // Nonexistent key should not cause an error. + $backend->deleteMultiple(array( + 'test3', + 'test5', + 'test7', + 'test19', // Nonexistent key should not cause an error. + 'test21', // Nonexistent key should not cause an error. + )); + + // Test if expected keys have been deleted. + $this->assertIdentical(FALSE, $backend->get('test1'), "Cache id test1 deleted."); + $this->assertIdentical(FALSE, $backend->get('test3'), "Cache id test3 deleted."); + $this->assertIdentical(FALSE, $backend->get('test5'), "Cache id test5 deleted."); + $this->assertIdentical(FALSE, $backend->get('test7'), "Cache id test7 deleted."); + + // Test if expected keys exist. + $this->assertNotIdentical(FALSE, $backend->get('test2'), "Cache id test2 exists."); + $this->assertNotIdentical(FALSE, $backend->get('test4'), "Cache id test4 exists."); + $this->assertNotIdentical(FALSE, $backend->get('test6'), "Cache id test6 exists."); + + // Test if that expected keys do not exist. + $this->assertIdentical(FALSE, $backend->get('test19'), "Cache id test19 does not exist."); + $this->assertIdentical(FALSE, $backend->get('test21'), "Cache id test21 does not exist."); + + // Calling deleteMultiple() with an empty array should not cause an error. + $this->assertFalse($backend->deleteMultiple(array())); + } + + /** + * Test Drupal\Core\Cache\CacheBackendInterface::deleteAll(). + */ + public function testDeleteAll() { + $backend_a = $this->getCacheBackend(); + $backend_b = $this->getCacheBackend('bootstrap'); + + // Set both expiring and permanent keys. + $backend_a->set('test1', 1, Cache::PERMANENT); + $backend_a->set('test2', 3, time() + 1000); + $backend_b->set('test3', 4, Cache::PERMANENT); + + $backend_a->deleteAll(); + + $this->assertFalse($backend_a->get('test1'), 'First key has been deleted.'); + $this->assertFalse($backend_a->get('test2'), 'Second key has been deleted.'); + $this->assertTrue($backend_b->get('test3'), 'Item in other bin is preserved.'); + } + + /** + * Test Drupal\Core\Cache\CacheBackendInterface::invalidate() and + * Drupal\Core\Cache\CacheBackendInterface::invalidateMultiple(). + */ + function testInvalidate() { + $backend = $this->getCacheBackend(); + $backend->set('test1', 1); + $backend->set('test2', 2); + $backend->set('test3', 2); + $backend->set('test4', 2); + + $reference = array('test1', 'test2', 'test3', 'test4'); + + $cids = $reference; + $ret = $backend->getMultiple($cids); + $this->assertEqual(count($ret), 4, 'Four items returned.'); + + $backend->invalidate('test1'); + $backend->invalidateMultiple(array('test2', 'test3')); + + $cids = $reference; + $ret = $backend->getMultiple($cids); + $this->assertEqual(count($ret), 1, 'Only one item element returned.'); + + $cids = $reference; + $ret = $backend->getMultiple($cids, TRUE); + $this->assertEqual(count($ret), 4, 'Four items returned.'); + + // Calling invalidateMultiple() with an empty array should not cause an + // error. + $this->assertFalse($backend->invalidateMultiple(array())); + } + + /** + * Tests Drupal\Core\Cache\CacheBackendInterface::invalidateTags(). + */ + function testInvalidateTags() { + $backend = $this->getCacheBackend(); + + // Create two cache entries with the same tag and tag value. + $backend->set('test_cid_invalidate1', $this->defaultValue, Cache::PERMANENT, array('test_tag:2')); + $backend->set('test_cid_invalidate2', $this->defaultValue, Cache::PERMANENT, array('test_tag:2')); + $this->assertTrue($backend->get('test_cid_invalidate1') && $backend->get('test_cid_invalidate2'), 'Two cache items were created.'); + + // Invalidate test_tag of value 1. This should invalidate both entries. + Cache::invalidateTags(array('test_tag:2')); + $this->assertFalse($backend->get('test_cid_invalidate1') || $backend->get('test_cid_invalidate2'), 'Two cache items invalidated after invalidating a cache tag.'); + $this->assertTrue($backend->get('test_cid_invalidate1', TRUE) && $backend->get('test_cid_invalidate2', TRUE), 'Cache items not deleted after invalidating a cache tag.'); + + // Create two cache entries with the same tag and an array tag value. + $backend->set('test_cid_invalidate1', $this->defaultValue, Cache::PERMANENT, array('test_tag:1')); + $backend->set('test_cid_invalidate2', $this->defaultValue, Cache::PERMANENT, array('test_tag:1')); + $this->assertTrue($backend->get('test_cid_invalidate1') && $backend->get('test_cid_invalidate2'), 'Two cache items were created.'); + + // Invalidate test_tag of value 1. This should invalidate both entries. + Cache::invalidateTags(array('test_tag:1')); + $this->assertFalse($backend->get('test_cid_invalidate1') || $backend->get('test_cid_invalidate2'), 'Two caches removed after invalidating a cache tag.'); + $this->assertTrue($backend->get('test_cid_invalidate1', TRUE) && $backend->get('test_cid_invalidate2', TRUE), 'Cache items not deleted after invalidating a cache tag.'); + + // Create three cache entries with a mix of tags and tag values. + $backend->set('test_cid_invalidate1', $this->defaultValue, Cache::PERMANENT, array('test_tag:1')); + $backend->set('test_cid_invalidate2', $this->defaultValue, Cache::PERMANENT, array('test_tag:2')); + $backend->set('test_cid_invalidate3', $this->defaultValue, Cache::PERMANENT, array('test_tag_foo:3')); + $this->assertTrue($backend->get('test_cid_invalidate1') && $backend->get('test_cid_invalidate2') && $backend->get('test_cid_invalidate3'), 'Three cached items were created.'); + Cache::invalidateTags(array('test_tag_foo:3')); + $this->assertTrue($backend->get('test_cid_invalidate1') && $backend->get('test_cid_invalidate2'), 'Cache items not matching the tag were not invalidated.'); + $this->assertFalse($backend->get('test_cid_invalidated3'), 'Cached item matching the tag was removed.'); + + // Create cache entry in multiple bins. Two cache entries + // (test_cid_invalidate1 and test_cid_invalidate2) still exist from previous + // tests. + $tags = array('test_tag:1', 'test_tag:2', 'test_tag:3'); + $bins = array('path', 'bootstrap', 'page'); + foreach ($bins as $bin) { + $this->getCacheBackend($bin)->set('test', $this->defaultValue, Cache::PERMANENT, $tags); + $this->assertTrue($this->getCacheBackend($bin)->get('test'), 'Cache item was set in bin.'); + } + + Cache::invalidateTags(array('test_tag:2')); + + // Test that the cache entry has been invalidated in multiple bins. + foreach ($bins as $bin) { + $this->assertFalse($this->getCacheBackend($bin)->get('test'), 'Tag invalidation affected item in bin.'); + } + // Test that the cache entry with a matching tag has been invalidated. + $this->assertFalse($this->getCacheBackend($bin)->get('test_cid_invalidate2'), 'Cache items matching tag were invalidated.'); + // Test that the cache entry with without a matching tag still exists. + $this->assertTrue($this->getCacheBackend($bin)->get('test_cid_invalidate1'), 'Cache items not matching tag were not invalidated.'); + } + + /** + * Test Drupal\Core\Cache\CacheBackendInterface::invalidateAll(). + */ + public function testInvalidateAll() { + $backend_a = $this->getCacheBackend(); + $backend_b = $this->getCacheBackend('bootstrap'); + + // Set both expiring and permanent keys. + $backend_a->set('test1', 1, Cache::PERMANENT); + $backend_a->set('test2', 3, time() + 1000); + $backend_b->set('test3', 4, Cache::PERMANENT); + + $backend_a->invalidateAll(); + + $this->assertFalse($backend_a->get('test1'), 'First key has been invalidated.'); + $this->assertFalse($backend_a->get('test2'), 'Second key has been invalidated.'); + $this->assertTrue($backend_b->get('test3'), 'Item in other bin is preserved.'); + $this->assertTrue($backend_a->get('test1', TRUE), 'First key has not been deleted.'); + $this->assertTrue($backend_a->get('test2', TRUE), 'Second key has not been deleted.'); + } + + /** + * Tests Drupal\Core\Cache\CacheBackendInterface::removeBin(). + */ + public function testRemoveBin() { + $backend_a = $this->getCacheBackend(); + $backend_b = $this->getCacheBackend('bootstrap'); + + // Set both expiring and permanent keys. + $backend_a->set('test1', 1, Cache::PERMANENT); + $backend_a->set('test2', 3, time() + 1000); + $backend_b->set('test3', 4, Cache::PERMANENT); + + $backend_a->removeBin(); + + $this->assertFalse($backend_a->get('test1'), 'First key has been deleted.'); + $this->assertFalse($backend_a->get('test2', TRUE), 'Second key has been deleted.'); + $this->assertTrue($backend_b->get('test3'), 'Item in other bin is preserved.'); + } + +} diff --git a/core/modules/system/tests/src/Functional/Cache/PageCacheTagsTestBase.php b/core/modules/system/tests/src/Functional/Cache/PageCacheTagsTestBase.php new file mode 100644 index 0000000..de2b419 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Cache/PageCacheTagsTestBase.php @@ -0,0 +1,62 @@ +config('system.performance'); + $config->set('cache.page.max_age', 3600); + $config->save(); + } + + /** + * Verify that when loading a given page, it's a page cache hit or miss. + * + * @param \Drupal\Core\Url $url + * The page for this URL will be loaded. + * @param string $hit_or_miss + * 'HIT' if a page cache hit is expected, 'MISS' otherwise. + * + * @param array|false $tags + * When expecting a page cache hit, you may optionally specify an array of + * expected cache tags. While FALSE, the cache tags will not be verified. + */ + protected function verifyPageCache(Url $url, $hit_or_miss, $tags = FALSE) { + $this->drupalGet($url); + $message = SafeMarkup::format('Page cache @hit_or_miss for %path.', array('@hit_or_miss' => $hit_or_miss, '%path' => $url->toString())); + $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), $hit_or_miss, $message); + + if ($hit_or_miss === 'HIT' && is_array($tags)) { + $absolute_url = $url->setAbsolute()->toString(); + $cid_parts = array($absolute_url, 'html'); + $cid = implode(':', $cid_parts); + $cache_entry = \Drupal::cache('render')->get($cid); + sort($cache_entry->tags); + $tags = array_unique($tags); + sort($tags); + $this->assertIdentical($cache_entry->tags, $tags); + } + } + +} diff --git a/core/modules/system/src/Tests/Cache/SessionExistsCacheContextTest.php b/core/modules/system/tests/src/Functional/Cache/SessionExistsCacheContextTest.php similarity index 93% rename from core/modules/system/src/Tests/Cache/SessionExistsCacheContextTest.php rename to core/modules/system/tests/src/Functional/Cache/SessionExistsCacheContextTest.php index b3c195d..167a913 100644 --- a/core/modules/system/src/Tests/Cache/SessionExistsCacheContextTest.php +++ b/core/modules/system/tests/src/Functional/Cache/SessionExistsCacheContextTest.php @@ -1,16 +1,16 @@ alter(). * * @group Common */ -class AlterTest extends WebTestBase { +class AlterTest extends BrowserTestBase { /** * Modules to enable. diff --git a/core/modules/system/src/Tests/Common/NoJavaScriptAnonymousTest.php b/core/modules/system/tests/src/Functional/Common/NoJavaScriptAnonymousTest.php similarity index 92% rename from core/modules/system/src/Tests/Common/NoJavaScriptAnonymousTest.php rename to core/modules/system/tests/src/Functional/Common/NoJavaScriptAnonymousTest.php index 0e1e298..dad5c4a2 100644 --- a/core/modules/system/src/Tests/Common/NoJavaScriptAnonymousTest.php +++ b/core/modules/system/tests/src/Functional/Common/NoJavaScriptAnonymousTest.php @@ -1,8 +1,8 @@ grantPermission('view test entity'); + $user_role->save(); + + // Create an entity. + $this->entity = $this->createEntity(); + + // If this is an entity with field UI enabled, then add a configurable + // field. We will use this configurable field in later tests to ensure that + // field configuration invalidate render cache entries. + if ($this->entity->getEntityType()->get('field_ui_base_route')) { + // Add field, so we can modify the field storage and field entities to + // verify that changes to those indeed clear cache tags. + FieldStorageConfig::create(array( + 'field_name' => 'configurable_field', + 'entity_type' => $this->entity->getEntityTypeId(), + 'type' => 'test_field', + 'settings' => array(), + ))->save(); + FieldConfig::create([ + 'entity_type' => $this->entity->getEntityTypeId(), + 'bundle' => $this->entity->bundle(), + 'field_name' => 'configurable_field', + 'label' => 'Configurable field', + 'settings' => array(), + ])->save(); + + // Reload the entity now that a new field has been added to it. + $storage = $this->container + ->get('entity.manager') + ->getStorage($this->entity->getEntityTypeId()); + $storage->resetCache(); + $this->entity = $storage->load($this->entity->id()); + } + + // Create a referencing and a non-referencing entity. + list( + $this->referencingEntity, + $this->nonReferencingEntity, + ) = $this->createReferenceTestEntities($this->entity); + } + + /** + * Generates standardized entity cache tags test info. + * + * @param string $entity_type_label + * The label of the entity type whose cache tags to test. + * @param string $group + * The test group. + * + * @return array + * + * @see \Drupal\simpletest\TestBase::getInfo() + */ + protected static function generateStandardizedInfo($entity_type_label, $group) { + return array( + 'name' => "$entity_type_label entity cache tags", + 'description' => "Test the $entity_type_label entity's cache tags.", + 'group' => $group, + ); + } + + /** + * Creates the entity to be tested. + * + * @return \Drupal\Core\Entity\EntityInterface + * The entity to be tested. + */ + abstract protected function createEntity(); + + /** + * Returns the access cache contexts for the tested entity. + * + * Only list cache contexts that aren't part of the required cache contexts. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to be tested, as created by createEntity(). + * + * @return string[] + * An array of the additional cache contexts. + * + * @see \Drupal\Core\Entity\EntityAccessControlHandlerInterface + */ + protected function getAccessCacheContextsForEntity(EntityInterface $entity) { + return []; + } + + /** + * Returns the additional (non-standard) cache contexts for the tested entity. + * + * Only list cache contexts that aren't part of the required cache contexts. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to be tested, as created by createEntity(). + * + * @return string[] + * An array of the additional cache contexts. + * + * @see \Drupal\system\Tests\Entity\EntityCacheTagsTestBase::createEntity() + */ + protected function getAdditionalCacheContextsForEntity(EntityInterface $entity) { + return []; + } + + /** + * Returns the additional (non-standard) cache tags for the tested entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to be tested, as created by createEntity(). + * @return array + * An array of the additional cache tags. + * + * @see \Drupal\system\Tests\Entity\EntityCacheTagsTestBase::createEntity() + */ + protected function getAdditionalCacheTagsForEntity(EntityInterface $entity) { + return array(); + } + + /** + * Returns the additional cache tags for the tested entity's listing by type. + * + * @return string[] + * An array of the additional cache contexts. + */ + protected function getAdditionalCacheContextsForEntityListing() { + return []; + } + + /** + * Returns the additional cache tags for the tested entity's listing by type. + * + * Necessary when there are unavoidable default entities of this type, e.g. + * the anonymous and administrator User entities always exist. + * + * @return array + * An array of the additional cache tags. + */ + protected function getAdditionalCacheTagsForEntityListing() { + return []; + } + + /** + * Selects the preferred view mode for the given entity type. + * + * Prefers 'full', picks the first one otherwise, and if none are available, + * chooses 'default'. + */ + protected function selectViewMode($entity_type) { + $view_modes = \Drupal::entityManager() + ->getStorage('entity_view_mode') + ->loadByProperties(array('targetEntityType' => $entity_type)); + + if (empty($view_modes)) { + return 'default'; + } + else { + // Prefer the "full" display mode. + if (isset($view_modes[$entity_type . '.full'])) { + return 'full'; + } + else { + $view_modes = array_keys($view_modes); + return substr($view_modes[0], strlen($entity_type) + 1); + } + } + } + + /** + * Creates a referencing and a non-referencing entity for testing purposes. + * + * @param \Drupal\Core\Entity\EntityInterface $referenced_entity + * The entity that the referencing entity should reference. + * + * @return \Drupal\Core\Entity\EntityInterface[] + * An array containing a referencing entity and a non-referencing entity. + */ + protected function createReferenceTestEntities($referenced_entity) { + // All referencing entities should be of the type 'entity_test'. + $entity_type = 'entity_test'; + + // Create a "foo" bundle for the given entity type. + $bundle = 'foo'; + entity_test_create_bundle($bundle, NULL, $entity_type); + + // Add a field of the given type to the given entity type's "foo" bundle. + $field_name = $referenced_entity->getEntityTypeId() . '_reference'; + FieldStorageConfig::create(array( + 'field_name' => $field_name, + 'entity_type' => $entity_type, + 'type' => 'entity_reference', + 'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, + 'settings' => array( + 'target_type' => $referenced_entity->getEntityTypeId(), + ), + ))->save(); + FieldConfig::create([ + 'field_name' => $field_name, + 'entity_type' => $entity_type, + 'bundle' => $bundle, + 'settings' => array( + 'handler' => 'default', + 'handler_settings' => array( + 'target_bundles' => array( + $referenced_entity->bundle() => $referenced_entity->bundle(), + ), + 'sort' => array('field' => '_none'), + 'auto_create' => FALSE, + ), + ), + ])->save(); + if (!$this->entity->getEntityType()->hasHandlerClass('view_builder')) { + entity_get_display($entity_type, $bundle, 'full') + ->setComponent($field_name, array( + 'type' => 'entity_reference_label', + )) + ->save(); + } + else { + $referenced_entity_view_mode = $this->selectViewMode($this->entity->getEntityTypeId()); + entity_get_display($entity_type, $bundle, 'full') + ->setComponent($field_name, array( + 'type' => 'entity_reference_entity_view', + 'settings' => array( + 'view_mode' => $referenced_entity_view_mode, + ), + )) + ->save(); + } + + // Create an entity that does reference the entity being tested. + $label_key = \Drupal::entityManager()->getDefinition($entity_type)->getKey('label'); + $referencing_entity = $this->container->get('entity_type.manager') + ->getStorage($entity_type) + ->create(array( + $label_key => 'Referencing ' . $entity_type, + 'status' => 1, + 'type' => $bundle, + $field_name => array('target_id' => $referenced_entity->id()), + )); + $referencing_entity->save(); + + // Create an entity that does not reference the entity being tested. + $non_referencing_entity = $this->container->get('entity_type.manager') + ->getStorage($entity_type) + ->create(array( + $label_key => 'Non-referencing ' . $entity_type, + 'status' => 1, + 'type' => $bundle, + )); + $non_referencing_entity->save(); + + return array( + $referencing_entity, + $non_referencing_entity, + ); + } + + /** + * Tests cache tags presence and invalidation of the entity when referenced. + * + * Tests the following cache tags: + * - entity type view cache tag: "_view" + * - entity cache tag: ":" + * - entity type list cache tag: "_list" + * - referencing entity type view cache tag: "_view" + * - referencing entity type cache tag: ":" + */ + public function testReferencedEntity() { + $entity_type = $this->entity->getEntityTypeId(); + $referencing_entity_url = $this->referencingEntity->urlInfo('canonical'); + $non_referencing_entity_url = $this->nonReferencingEntity->urlInfo('canonical'); + $listing_url = Url::fromRoute('entity.entity_test.collection_referencing_entities', [ + 'entity_reference_field_name' => $entity_type . '_reference', + 'referenced_entity_type' => $entity_type, + 'referenced_entity_id' => $this->entity->id(), + ]); + $empty_entity_listing_url = Url::fromRoute('entity.entity_test.collection_empty', ['entity_type_id' => $entity_type]); + $nonempty_entity_listing_url = Url::fromRoute('entity.entity_test.collection_labels_alphabetically', ['entity_type_id' => $entity_type]); + + // The default cache contexts for rendered entities. + $default_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions']; + $entity_cache_contexts = $default_cache_contexts; + $page_cache_contexts = Cache::mergeContexts($default_cache_contexts, ['url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT]); + + // Cache tags present on every rendered page. + // 'user.permissions' is a required cache context, and responses that vary + // by this cache context when requested by anonymous users automatically + // also get this cache tag, to ensure correct invalidation. + $page_cache_tags = Cache::mergeTags(['rendered'], ['config:user.role.anonymous']); + // If the block module is used, the Block page display variant is used, + // which adds the block config entity type's list cache tags. + $page_cache_tags = Cache::mergeTags($page_cache_tags, \Drupal::moduleHandler()->moduleExists('block') ? ['config:block_list'] : []); + + $page_cache_tags_referencing_entity = in_array('user.permissions', $this->getAccessCacheContextsForEntity($this->referencingEntity)) ? ['config:user.role.anonymous'] : []; + + $view_cache_tag = array(); + if ($this->entity->getEntityType()->hasHandlerClass('view_builder')) { + $view_cache_tag = \Drupal::entityManager()->getViewBuilder($entity_type) + ->getCacheTags(); + } + + $context_metadata = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($entity_cache_contexts); + $cache_context_tags = $context_metadata->getCacheTags(); + + // Generate the cache tags for the (non) referencing entities. + $referencing_entity_cache_tags = Cache::mergeTags($this->referencingEntity->getCacheTags(), \Drupal::entityManager()->getViewBuilder('entity_test')->getCacheTags()); + // Includes the main entity's cache tags, since this entity references it. + $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, $this->entity->getCacheTags()); + $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, $this->getAdditionalCacheTagsForEntity($this->entity)); + $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, $view_cache_tag); + $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, $cache_context_tags); + $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, ['rendered']); + + $non_referencing_entity_cache_tags = Cache::mergeTags($this->nonReferencingEntity->getCacheTags(), \Drupal::entityManager()->getViewBuilder('entity_test')->getCacheTags()); + $non_referencing_entity_cache_tags = Cache::mergeTags($non_referencing_entity_cache_tags, ['rendered']); + + // Generate the cache tags for all two possible entity listing paths. + // 1. list cache tag only (listing query has no match) + // 2. list cache tag plus entity cache tag (listing query has a match) + $empty_entity_listing_cache_tags = Cache::mergeTags($this->entity->getEntityType()->getListCacheTags(), $page_cache_tags); + + $nonempty_entity_listing_cache_tags = Cache::mergeTags($this->entity->getEntityType()->getListCacheTags(), $this->entity->getCacheTags()); + $nonempty_entity_listing_cache_tags = Cache::mergeTags($nonempty_entity_listing_cache_tags, $this->getAdditionalCacheTagsForEntityListing($this->entity)); + $nonempty_entity_listing_cache_tags = Cache::mergeTags($nonempty_entity_listing_cache_tags, $page_cache_tags); + + $this->pass("Test referencing entity.", 'Debug'); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + + // Verify a cache hit, but also the presence of the correct cache tags. + $expected_tags = Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags); + $expected_tags = Cache::mergeTags($expected_tags, $page_cache_tags_referencing_entity); + $this->verifyPageCache($referencing_entity_url, 'HIT', $expected_tags); + + // Also verify the existence of an entity render cache entry. + $cache_keys = ['entity_view', 'entity_test', $this->referencingEntity->id(), 'full']; + $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); + $access_cache_contexts = $this->getAccessCacheContextsForEntity($this->entity); + $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->referencingEntity); + $redirected_cid = NULL; + if (count($access_cache_contexts) || count($additional_cache_contexts)) { + $cache_contexts = Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts); + $cache_contexts = Cache::mergeContexts($cache_contexts, $access_cache_contexts); + $redirected_cid = $this->createCacheId($cache_keys, $cache_contexts); + $context_metadata = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($cache_contexts); + $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, $context_metadata->getCacheTags()); + } + $this->verifyRenderCache($cid, $referencing_entity_cache_tags, $redirected_cid); + + $this->pass("Test non-referencing entity.", 'Debug'); + $this->verifyPageCache($non_referencing_entity_url, 'MISS'); + // Verify a cache hit, but also the presence of the correct cache tags. + $this->verifyPageCache($non_referencing_entity_url, 'HIT', Cache::mergeTags($non_referencing_entity_cache_tags, $page_cache_tags)); + // Also verify the existence of an entity render cache entry. + $cache_keys = ['entity_view', 'entity_test', $this->nonReferencingEntity->id(), 'full']; + $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); + $this->verifyRenderCache($cid, $non_referencing_entity_cache_tags); + + + $this->pass("Test listing of referencing entities.", 'Debug'); + // Prime the page cache for the listing of referencing entities. + $this->verifyPageCache($listing_url, 'MISS'); + + // Verify a cache hit, but also the presence of the correct cache tags. + $expected_tags = Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags); + $expected_tags = Cache::mergeTags($expected_tags, $page_cache_tags_referencing_entity); + $this->verifyPageCache($listing_url, 'HIT', $expected_tags); + + $this->pass("Test empty listing.", 'Debug'); + // Prime the page cache for the empty listing. + $this->verifyPageCache($empty_entity_listing_url, 'MISS'); + // Verify a cache hit, but also the presence of the correct cache tags. + $this->verifyPageCache($empty_entity_listing_url, 'HIT', $empty_entity_listing_cache_tags); + // Verify the entity type's list cache contexts are present. + $contexts_in_header = $this->drupalGetHeader('X-Drupal-Cache-Contexts'); + $this->assertEqual(Cache::mergeContexts($page_cache_contexts, $this->getAdditionalCacheContextsForEntityListing()), empty($contexts_in_header) ? [] : explode(' ', $contexts_in_header)); + + + $this->pass("Test listing containing referenced entity.", 'Debug'); + // Prime the page cache for the listing containing the referenced entity. + $this->verifyPageCache($nonempty_entity_listing_url, 'MISS', $nonempty_entity_listing_cache_tags); + // Verify a cache hit, but also the presence of the correct cache tags. + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT', $nonempty_entity_listing_cache_tags); + // Verify the entity type's list cache contexts are present. + $contexts_in_header = $this->drupalGetHeader('X-Drupal-Cache-Contexts'); + $this->assertEqual(Cache::mergeContexts($page_cache_contexts, $this->getAdditionalCacheContextsForEntityListing()), empty($contexts_in_header) ? [] : explode(' ', $contexts_in_header)); + + + // Verify that after modifying the referenced entity, there is a cache miss + // for every route except the one for the non-referencing entity. + $this->pass("Test modification of referenced entity.", 'Debug'); + $this->entity->save(); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($empty_entity_listing_url, 'MISS'); + $this->verifyPageCache($nonempty_entity_listing_url, 'MISS'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + + + // Verify that after modifying the referencing entity, there is a cache miss + // for every route except the ones for the non-referencing entity and the + // empty entity listing. + $this->pass("Test modification of referencing entity.", 'Debug'); + $this->referencingEntity->save(); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + + + // Verify that after modifying the non-referencing entity, there is a cache + // miss only for the non-referencing entity route. + $this->pass("Test modification of non-referencing entity.", 'Debug'); + $this->nonReferencingEntity->save(); + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + $this->verifyPageCache($non_referencing_entity_url, 'MISS'); + + // Verify cache hits. + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + + + if ($this->entity->getEntityType()->hasHandlerClass('view_builder')) { + // Verify that after modifying the entity's display, there is a cache miss + // for both the referencing entity, and the listing of referencing + // entities, but not for any other routes. + $referenced_entity_view_mode = $this->selectViewMode($this->entity->getEntityTypeId()); + $this->pass("Test modification of referenced entity's '$referenced_entity_view_mode' display.", 'Debug'); + $entity_display = entity_get_display($entity_type, $this->entity->bundle(), $referenced_entity_view_mode); + $entity_display->save(); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + } + + + if ($bundle_entity_type_id = $this->entity->getEntityType()->getBundleEntityType()) { + // Verify that after modifying the corresponding bundle entity, there is a + // cache miss for both the referencing entity, and the listing of + // referencing entities, but not for any other routes. + $this->pass("Test modification of referenced entity's bundle entity.", 'Debug'); + $bundle_entity = $this->container->get('entity_type.manager') + ->getStorage($bundle_entity_type_id) + ->load($this->entity->bundle()); + $bundle_entity->save(); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + // Special case: entity types may choose to use their bundle entity type + // cache tags, to avoid having excessively granular invalidation. + $is_special_case = $bundle_entity->getCacheTags() == $this->entity->getCacheTags() && $bundle_entity->getEntityType()->getListCacheTags() == $this->entity->getEntityType()->getListCacheTags(); + if ($is_special_case) { + $this->verifyPageCache($empty_entity_listing_url, 'MISS'); + $this->verifyPageCache($nonempty_entity_listing_url, 'MISS'); + } + else { + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + } + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + if ($is_special_case) { + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + } + } + + + if ($this->entity->getEntityType()->get('field_ui_base_route')) { + // Verify that after modifying a configurable field on the entity, there + // is a cache miss. + $this->pass("Test modification of referenced entity's configurable field.", 'Debug'); + $field_storage_name = $this->entity->getEntityTypeId() . '.configurable_field'; + $field_storage = FieldStorageConfig::load($field_storage_name); + $field_storage->save(); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + + + // Verify that after modifying a configurable field on the entity, there + // is a cache miss. + $this->pass("Test modification of referenced entity's configurable field.", 'Debug'); + $field_name = $this->entity->getEntityTypeId() . '.' . $this->entity->bundle() . '.configurable_field'; + $field = FieldConfig::load($field_name); + $field->save(); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + } + + + // Verify that after invalidating the entity's cache tag directly, there is + // a cache miss for every route except the ones for the non-referencing + // entity and the empty entity listing. + $this->pass("Test invalidation of referenced entity's cache tag.", 'Debug'); + Cache::invalidateTags($this->entity->getCacheTagsToInvalidate()); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($nonempty_entity_listing_url, 'MISS'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + + // Verify that after invalidating the entity's list cache tag directly, + // there is a cache miss for both the empty entity listing and the non-empty + // entity listing routes, but not for other routes. + $this->pass("Test invalidation of referenced entity's list cache tag.", 'Debug'); + Cache::invalidateTags($this->entity->getEntityType()->getListCacheTags()); + $this->verifyPageCache($empty_entity_listing_url, 'MISS'); + $this->verifyPageCache($nonempty_entity_listing_url, 'MISS'); + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + + + if (!empty($view_cache_tag)) { + // Verify that after invalidating the generic entity type's view cache tag + // directly, there is a cache miss for both the referencing entity, and the + // listing of referencing entities, but not for other routes. + $this->pass("Test invalidation of referenced entity's 'view' cache tag.", 'Debug'); + Cache::invalidateTags($view_cache_tag); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + $this->verifyPageCache($empty_entity_listing_url, 'HIT'); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT'); + + // Verify cache hits. + $this->verifyPageCache($referencing_entity_url, 'HIT'); + $this->verifyPageCache($listing_url, 'HIT'); + } + + // Verify that after deleting the entity, there is a cache miss for every + // route except for the non-referencing entity one. + $this->pass('Test deletion of referenced entity.', 'Debug'); + $this->entity->delete(); + $this->verifyPageCache($referencing_entity_url, 'MISS'); + $this->verifyPageCache($listing_url, 'MISS'); + $this->verifyPageCache($empty_entity_listing_url, 'MISS'); + $this->verifyPageCache($nonempty_entity_listing_url, 'MISS'); + $this->verifyPageCache($non_referencing_entity_url, 'HIT'); + + // Verify cache hits. + $referencing_entity_cache_tags = Cache::mergeTags($this->referencingEntity->getCacheTags(), \Drupal::entityManager()->getViewBuilder('entity_test')->getCacheTags()); + $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, ['rendered']); + + $nonempty_entity_listing_cache_tags = Cache::mergeTags($this->entity->getEntityType()->getListCacheTags(), $this->getAdditionalCacheTagsForEntityListing()); + $nonempty_entity_listing_cache_tags = Cache::mergeTags($nonempty_entity_listing_cache_tags, $page_cache_tags); + + $this->verifyPageCache($referencing_entity_url, 'HIT', Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags)); + $this->verifyPageCache($listing_url, 'HIT', $page_cache_tags); + $this->verifyPageCache($empty_entity_listing_url, 'HIT', $empty_entity_listing_cache_tags); + $this->verifyPageCache($nonempty_entity_listing_url, 'HIT', $nonempty_entity_listing_cache_tags); + } + + /** + * Creates a cache ID from a list of cache keys and a set of cache contexts. + * + * @param string[] $keys + * A list of cache keys. + * @param string[] $contexts + * A set of cache contexts. + * + * @return string + * The cache ID string. + */ + protected function createCacheId(array $keys, array $contexts) { + $cid_parts = $keys; + + $contexts = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($contexts); + $cid_parts = array_merge($cid_parts, $contexts->getKeys()); + + return implode(':', $cid_parts); + } + + /** + * Verify that a given render cache entry exists, with the correct cache tags. + * + * @param string $cid + * The render cache item ID. + * @param array $tags + * An array of expected cache tags. + * @param string|null $redirected_cid + * (optional) The redirected render cache item ID. + */ + protected function verifyRenderCache($cid, array $tags, $redirected_cid = NULL) { + // Also verify the existence of an entity render cache entry. + $cache_entry = \Drupal::cache('render')->get($cid); + $this->assertTrue($cache_entry, 'A render cache entry exists.'); + sort($cache_entry->tags); + sort($tags); + $this->assertIdentical($cache_entry->tags, $tags); + $is_redirecting_cache_item = isset($cache_entry->data['#cache_redirect']); + if ($redirected_cid === NULL) { + $this->assertFalse($is_redirecting_cache_item, 'Render cache entry is not a redirect.'); + // If this is a redirecting cache item unlike we expected, log it. + if ($is_redirecting_cache_item) { + debug($cache_entry->data); + } + } + else { + // Verify that $cid contains a cache redirect. + $this->assertTrue($is_redirecting_cache_item, 'Render cache entry is a redirect.'); + // If this is not a redirecting cache item unlike we expected, log it. + if (!$is_redirecting_cache_item) { + debug($cache_entry->data); + } + // Verify that the cache redirect points to the expected CID. + $redirect_cache_metadata = $cache_entry->data['#cache']; + $actual_redirection_cid = $this->createCacheId( + $redirect_cache_metadata['keys'], + $redirect_cache_metadata['contexts'] + ); + $this->assertIdentical($redirected_cid, $actual_redirection_cid); + // Finally, verify that the redirected CID exists and has the same cache + // tags. + $this->verifyRenderCache($redirected_cid, $tags); + } + } + +} diff --git a/core/modules/system/tests/src/Functional/Entity/EntityDefinitionTestTrait.php b/core/modules/system/tests/src/Functional/Entity/EntityDefinitionTestTrait.php new file mode 100644 index 0000000..e4e1b55 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Entity/EntityDefinitionTestTrait.php @@ -0,0 +1,269 @@ +state->set('entity_test_new', TRUE); + $this->entityManager->clearCachedDefinitions(); + $this->entityDefinitionUpdateManager->applyUpdates(); + } + + /** + * Resets the entity type definition. + */ + protected function resetEntityType() { + $this->state->set('entity_test_update.entity_type', NULL); + $this->entityManager->clearCachedDefinitions(); + $this->entityDefinitionUpdateManager->applyUpdates(); + } + + /** + * Updates the 'entity_test_update' entity type to revisionable. + */ + protected function updateEntityTypeToRevisionable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $keys = $entity_type->getKeys(); + $keys['revision'] = 'revision_id'; + $entity_type->set('entity_keys', $keys); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Updates the 'entity_test_update' entity type not revisionable. + */ + protected function updateEntityTypeToNotRevisionable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $keys = $entity_type->getKeys(); + unset($keys['revision']); + $entity_type->set('entity_keys', $keys); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Updates the 'entity_test_update' entity type to translatable. + */ + protected function updateEntityTypeToTranslatable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('translatable', TRUE); + $entity_type->set('data_table', 'entity_test_update_data'); + + if ($entity_type->isRevisionable()) { + $entity_type->set('revision_data_table', 'entity_test_update_revision_data'); + } + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Updates the 'entity_test_update' entity type to not translatable. + */ + protected function updateEntityTypeToNotTranslatable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('translatable', FALSE); + $entity_type->set('data_table', NULL); + + if ($entity_type->isRevisionable()) { + $entity_type->set('revision_data_table', NULL); + } + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Adds a new base field to the 'entity_test_update' entity type. + * + * @param string $type + * (optional) The field type for the new field. Defaults to 'string'. + */ + protected function addBaseField($type = 'string') { + $definitions['new_base_field'] = BaseFieldDefinition::create($type) + ->setName('new_base_field') + ->setLabel(t('A new base field')); + $this->state->set('entity_test_update.additional_base_field_definitions', $definitions); + } + + /** + * Adds a long-named base field to the 'entity_test_update' entity type. + */ + protected function addLongNameBaseField() { + $key = 'entity_test_update.additional_base_field_definitions'; + $definitions = $this->state->get($key, []); + $definitions['new_long_named_entity_reference_base_field'] = BaseFieldDefinition::create('entity_reference') + ->setName('new_long_named_entity_reference_base_field') + ->setLabel(t('A new long-named base field')) + ->setSetting('target_type', 'user') + ->setSetting('handler', 'default'); + $this->state->set($key, $definitions); + } + + /** + * Adds a new revisionable base field to the 'entity_test_update' entity type. + * + * @param string $type + * (optional) The field type for the new field. Defaults to 'string'. + */ + protected function addRevisionableBaseField($type = 'string') { + $definitions['new_base_field'] = BaseFieldDefinition::create($type) + ->setName('new_base_field') + ->setLabel(t('A new revisionable base field')) + ->setRevisionable(TRUE); + $this->state->set('entity_test_update.additional_base_field_definitions', $definitions); + } + + /** + * Modifies the new base field from 'string' to 'text'. + */ + protected function modifyBaseField() { + $this->addBaseField('text'); + } + + /** + * Promotes a field to an entity key. + */ + protected function makeBaseFieldEntityKey() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + $entity_keys = $entity_type->getKeys(); + $entity_keys['new_base_field'] = 'new_base_field'; + $entity_type->set('entity_keys', $entity_keys); + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Removes the new base field from the 'entity_test_update' entity type. + */ + protected function removeBaseField() { + $this->state->delete('entity_test_update.additional_base_field_definitions'); + } + + /** + * Adds a single-field index to the base field. + */ + protected function addBaseFieldIndex() { + $this->state->set('entity_test_update.additional_field_index.entity_test_update.new_base_field', TRUE); + } + + /** + * Removes the index added in addBaseFieldIndex(). + */ + protected function removeBaseFieldIndex() { + $this->state->delete('entity_test_update.additional_field_index.entity_test_update.new_base_field'); + } + + /** + * Adds a new bundle field to the 'entity_test_update' entity type. + * + * @param string $type + * (optional) The field type for the new field. Defaults to 'string'. + */ + protected function addBundleField($type = 'string') { + $definitions['new_bundle_field'] = FieldStorageDefinition::create($type) + ->setName('new_bundle_field') + ->setLabel(t('A new bundle field')) + ->setTargetEntityTypeId('entity_test_update'); + $this->state->set('entity_test_update.additional_field_storage_definitions', $definitions); + $this->state->set('entity_test_update.additional_bundle_field_definitions.test_bundle', $definitions); + } + + /** + * Modifies the new bundle field from 'string' to 'text'. + */ + protected function modifyBundleField() { + $this->addBundleField('text'); + } + + /** + * Removes the new bundle field from the 'entity_test_update' entity type. + */ + protected function removeBundleField() { + $this->state->delete('entity_test_update.additional_field_storage_definitions'); + $this->state->delete('entity_test_update.additional_bundle_field_definitions.test_bundle'); + } + + /** + * Adds an index to the 'entity_test_update' entity type's base table. + * + * @see \Drupal\entity_test\EntityTestStorageSchema::getEntitySchema() + */ + protected function addEntityIndex() { + $indexes = array( + 'entity_test_update__new_index' => array('name', 'user_id'), + ); + $this->state->set('entity_test_update.additional_entity_indexes', $indexes); + } + + /** + * Removes the index added in addEntityIndex(). + */ + protected function removeEntityIndex() { + $this->state->delete('entity_test_update.additional_entity_indexes'); + } + + /** + * Renames the base table to 'entity_test_update_new'. + */ + protected function renameBaseTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('base_table', 'entity_test_update_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Renames the data table to 'entity_test_update_data_new'. + */ + protected function renameDataTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('data_table', 'entity_test_update_data_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Renames the revision table to 'entity_test_update_revision_new'. + */ + protected function renameRevisionBaseTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('revision_table', 'entity_test_update_revision_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Renames the revision data table to 'entity_test_update_revision_data_new'. + */ + protected function renameRevisionDataTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('revision_data_table', 'entity_test_update_revision_data_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Removes the entity type. + */ + protected function deleteEntityType() { + $this->state->set('entity_test_update.entity_type', 'null'); + } + +} diff --git a/core/modules/system/src/Tests/Entity/EntityListBuilderTest.php b/core/modules/system/tests/src/Functional/Entity/EntityListBuilderTest.php similarity index 93% rename from core/modules/system/src/Tests/Entity/EntityListBuilderTest.php rename to core/modules/system/tests/src/Functional/Entity/EntityListBuilderTest.php index 2a849b2..6d70760 100644 --- a/core/modules/system/src/Tests/Entity/EntityListBuilderTest.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityListBuilderTest.php @@ -1,17 +1,17 @@ entityManager = $this->container->get('entity.manager'); + $this->state = $this->container->get('state'); + + $this->installSchema('system', 'sequences'); + + $this->installEntitySchema('user'); + $this->installEntitySchema('entity_test'); + + // If the concrete test sub-class installs the Node or Comment modules, + // ensure that the node and comment entity schema are created before the + // field configurations are installed. This is because the entity tables + // need to be created before the body field storage tables. This prevents + // trying to create the body field tables twice. + $class = get_class($this); + while ($class) { + if (property_exists($class, 'modules')) { + // Only check the modules, if the $modules property was not inherited. + $rp = new \ReflectionProperty($class, 'modules'); + if ($rp->class == $class) { + foreach (array_intersect(array('node', 'comment'), $class::$modules) as $module) { + $this->installEntitySchema($module); + } + if (in_array('forum', $class::$modules, TRUE)) { + // Forum module is particular about the order that dependencies are + // enabled in. The comment, node and taxonomy config and the + // taxonomy_term schema need to be installed before the forum config + // which in turn needs to be installed before field config. + $this->installConfig(['comment', 'node', 'taxonomy']); + $this->installEntitySchema('taxonomy_term'); + $this->installConfig(['forum']); + } + } + } + $class = get_parent_class($class); + } + + $this->installConfig(array('field')); + } + + /** + * Creates a user. + * + * @param array $values + * (optional) The values used to create the entity. + * @param array $permissions + * (optional) Array of permission names to assign to user. + * + * @return \Drupal\user\Entity\User + * The created user entity. + */ + protected function createUser($values = array(), $permissions = array()) { + if ($permissions) { + // Create a new role and apply permissions to it. + $role = Role::create(array( + 'id' => strtolower($this->randomMachineName(8)), + 'label' => $this->randomMachineName(8), + )); + $role->save(); + user_role_grant_permissions($role->id(), $permissions); + $values['roles'][] = $role->id(); + } + + $account = User::create($values + [ + 'name' => $this->randomMachineName(), + 'status' => 1, + ]); + $account->enforceIsNew(); + $account->save(); + return $account; + } + + /** + * Reloads the given entity from the storage and returns it. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to be reloaded. + * + * @return \Drupal\Core\Entity\EntityInterface + * The reloaded entity. + */ + protected function reloadEntity(EntityInterface $entity) { + $controller = $this->entityManager->getStorage($entity->getEntityTypeId()); + $controller->resetCache(array($entity->id())); + return $controller->load($entity->id()); + } + + /** + * Returns the entity_test hook invocation info. + * + * @return array + * An associative array of arbitrary hook data keyed by hook name. + */ + protected function getHooksInfo() { + $key = 'entity_test.hooks'; + $hooks = $this->state->get($key); + $this->state->set($key, array()); + return $hooks; + } + + /** + * Installs a module and refreshes services. + * + * @param string $module + * The module to install. + */ + protected function installModule($module) { + $this->enableModules(array($module)); + $this->refreshServices(); + } + + /** + * Uninstalls a module and refreshes services. + * + * @param string $module + * The module to uninstall. + */ + protected function uninstallModule($module) { + $this->disableModules(array($module)); + $this->refreshServices(); + } + + /** + * Refresh services. + */ + protected function refreshServices() { + $this->container = \Drupal::getContainer(); + $this->entityManager = $this->container->get('entity.manager'); + $this->state = $this->container->get('state'); + } + + /** + * Generates a random ID avoiding collisions. + * + * @param bool $string + * (optional) Whether the id should have string type. Defaults to FALSE. + * + * @return int|string + * The entity identifier. + */ + protected function generateRandomEntityId($string = FALSE) { + srand(time()); + do { + // 0x7FFFFFFF is the maximum allowed value for integers that works for all + // Drupal supported databases and is known to work for other databases + // like SQL Server 2014 and Oracle 10 too. + $id = $string ? $this->randomMachineName() : mt_rand(1, 0x7FFFFFFF); + } while (isset($this->generatedIds[$id])); + $this->generatedIds[$id] = $id; + return $id; + } + +} diff --git a/core/modules/system/src/Tests/Entity/EntityViewControllerTest.php b/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php similarity index 97% rename from core/modules/system/src/Tests/Entity/EntityViewControllerTest.php rename to core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php index d9f36ff..8718929 100644 --- a/core/modules/system/src/Tests/Entity/EntityViewControllerTest.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php @@ -1,16 +1,16 @@ _view" + * - ":" + */ + public function testEntityUri() { + $entity_url = $this->entity->urlInfo(); + $entity_type = $this->entity->getEntityTypeId(); + + // Selects the view mode that will be used. + $view_mode = $this->selectViewMode($entity_type); + + // The default cache contexts for rendered entities. + $entity_cache_contexts = $this->getDefaultCacheContexts(); + + // Generate the standardized entity cache tags. + $cache_tag = $this->entity->getCacheTags(); + $view_cache_tag = \Drupal::entityManager()->getViewBuilder($entity_type)->getCacheTags(); + $render_cache_tag = 'rendered'; + + + $this->pass("Test entity.", 'Debug'); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit, but also the presence of the correct cache tags. + $this->verifyPageCache($entity_url, 'HIT'); + + // Also verify the existence of an entity render cache entry, if this entity + // type supports render caching. + if (\Drupal::entityManager()->getDefinition($entity_type)->isRenderCacheable()) { + $cache_keys = ['entity_view', $entity_type, $this->entity->id(), $view_mode]; + $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); + $redirected_cid = NULL; + $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->entity); + if (count($additional_cache_contexts)) { + $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts)); + } + $expected_cache_tags = Cache::mergeTags($cache_tag, $view_cache_tag); + $expected_cache_tags = Cache::mergeTags($expected_cache_tags, $this->getAdditionalCacheTagsForEntity($this->entity)); + $expected_cache_tags = Cache::mergeTags($expected_cache_tags, array($render_cache_tag)); + $this->verifyRenderCache($cid, $expected_cache_tags, $redirected_cid); + } + + // Verify that after modifying the entity, there is a cache miss. + $this->pass("Test modification of entity.", 'Debug'); + $this->entity->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + + // Verify that after modifying the entity's display, there is a cache miss. + $this->pass("Test modification of entity's '$view_mode' display.", 'Debug'); + $entity_display = entity_get_display($entity_type, $this->entity->bundle(), $view_mode); + $entity_display->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + + if ($bundle_entity_type_id = $this->entity->getEntityType()->getBundleEntityType()) { + // Verify that after modifying the corresponding bundle entity, there is a + // cache miss. + $this->pass("Test modification of entity's bundle entity.", 'Debug'); + $bundle_entity = $this->container->get('entity_type.manager') + ->getStorage($bundle_entity_type_id) + ->load($this->entity->bundle()); + $bundle_entity->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + } + + + if ($this->entity->getEntityType()->get('field_ui_base_route')) { + // Verify that after modifying a configurable field on the entity, there + // is a cache miss. + $this->pass("Test modification of entity's configurable field.", 'Debug'); + $field_storage_name = $this->entity->getEntityTypeId() . '.configurable_field'; + $field_storage = FieldStorageConfig::load($field_storage_name); + $field_storage->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + // Verify that after modifying a configurable field on the entity, there + // is a cache miss. + $this->pass("Test modification of entity's configurable field.", 'Debug'); + $field_name = $this->entity->getEntityTypeId() . '.' . $this->entity->bundle() . '.configurable_field'; + $field = FieldConfig::load($field_name); + $field->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + } + + + // Verify that after invalidating the entity's cache tag directly, there is + // a cache miss. + $this->pass("Test invalidation of entity's cache tag.", 'Debug'); + Cache::invalidateTags($this->entity->getCacheTagsToInvalidate()); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + + // Verify that after invalidating the generic entity type's view cache tag + // directly, there is a cache miss. + $this->pass("Test invalidation of entity's 'view' cache tag.", 'Debug'); + Cache::invalidateTags($view_cache_tag); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + + // Verify that after deleting the entity, there is a cache miss. + $this->pass('Test deletion of entity.', 'Debug'); + $this->entity->delete(); + $this->verifyPageCache($entity_url, 'MISS'); + $this->assertResponse(404); + } + + /** + * Gets the default cache contexts for rendered entities. + * + * @return array + * The default cache contexts for rendered entities. + */ + protected function getDefaultCacheContexts() { + return ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions']; + } + +} diff --git a/core/modules/system/src/Tests/File/ConfigTest.php b/core/modules/system/tests/src/Functional/File/ConfigTest.php similarity index 93% rename from core/modules/system/src/Tests/File/ConfigTest.php rename to core/modules/system/tests/src/Functional/File/ConfigTest.php index 2313e26..ea73322 100644 --- a/core/modules/system/src/Tests/File/ConfigTest.php +++ b/core/modules/system/tests/src/Functional/File/ConfigTest.php @@ -1,15 +1,15 @@ container->get('config.storage'); + /** @var \Drupal\Core\Config\ConfigManagerInterface $config_manager */ + $config_manager = $this->container->get('config.manager'); + + $default_install_path = 'core/profiles/' . $this->profile . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY; + $profile_config_storage = new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION); + + foreach ($profile_config_storage->listAll() as $config_name) { + $result = $config_manager->diff($profile_config_storage, $active_config_storage, $config_name); + try { + $this->assertConfigDiff($result, $config_name, $skipped_config); + } + catch (\Exception $e) { + $this->fail($e->getMessage()); + } + } + } + +} diff --git a/core/modules/system/src/Tests/Installer/DistributionProfileTest.php b/core/modules/system/tests/src/Functional/Installer/DistributionProfileTest.php similarity index 97% rename from core/modules/system/src/Tests/Installer/DistributionProfileTest.php rename to core/modules/system/tests/src/Functional/Installer/DistributionProfileTest.php index 0bb47ac..59cb362 100644 --- a/core/modules/system/src/Tests/Installer/DistributionProfileTest.php +++ b/core/modules/system/tests/src/Functional/Installer/DistributionProfileTest.php @@ -1,6 +1,6 @@ drupalGet($goto); + } + $this->assertBreadcrumbParts($trail); + + // Additionally assert page title, if given. + if (isset($page_title)) { + $this->assertTitle(strtr('@title | Drupal', array('@title' => $page_title))); + } + + // Additionally assert active trail in a menu tree output, if given. + if ($tree) { + $this->assertMenuActiveTrail($tree, $last_active); + } + } + + /** + * Assert that a trail exists in the internal browser. + * + * @param array $trail + * An associative array whose keys are expected breadcrumb link paths and + * whose values are expected breadcrumb link texts (not sanitized). + */ + protected function assertBreadcrumbParts($trail) { + // Compare paths with actual breadcrumb. + $parts = $this->getBreadcrumbParts(); + $pass = TRUE; + // There may be more than one breadcrumb on the page. If $trail is empty + // this test would go into an infinite loop, so we need to check that too. + while ($trail && !empty($parts)) { + foreach ($trail as $path => $title) { + // If the path is empty, generate the path from the route. If + // the path does not start with a leading slash, then run it through + // Url::fromUri('base:')->toString() to get the correct base + // prepended. + if ($path == '') { + $url = Url::fromRoute('')->toString(); + } + elseif ($path[0] != '/') { + $url = Url::fromUri('base:' . $path)->toString(); + } + else { + $url = $path; + } + $part = array_shift($parts); + $pass = ($pass && $part['href'] === $url && $part['text'] === Html::escape($title)); + } + } + // No parts must be left, or an expected "Home" will always pass. + $pass = ($pass && empty($parts)); + + $this->assertTrue($pass, format_string('Breadcrumb %parts found on @path.', array( + '%parts' => implode(' » ', $trail), + '@path' => $this->getUrl(), + ))); + } + + /** + * Returns the breadcrumb contents of the current page in the internal browser. + */ + protected function getBreadcrumbParts() { + $parts = array(); + $elements = $this->xpath('//nav[@class="breadcrumb"]/ol/li/a'); + if (!empty($elements)) { + foreach ($elements as $element) { + $parts[] = array( + 'text' => (string) $element, + 'href' => (string) $element['href'], + 'title' => (string) $element['title'], + ); + } + } + return $parts; + } + +} diff --git a/core/modules/system/tests/src/Functional/Menu/AssertMenuActiveTrailTrait.php b/core/modules/system/tests/src/Functional/Menu/AssertMenuActiveTrailTrait.php new file mode 100644 index 0000000..aa96e27 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Menu/AssertMenuActiveTrailTrait.php @@ -0,0 +1,65 @@ + $link_title) { + $part_xpath = (!$i ? '//' : '/following-sibling::ul/descendant::'); + $part_xpath .= 'li[contains(@class, :class)]/a[contains(@href, :href) and contains(text(), :title)]'; + $part_args = array( + ':class' => 'menu-item--active-trail', + ':href' => Url::fromUri('base:' . $link_path)->toString(), + ':title' => $link_title, + ); + $xpath .= $this->buildXPathQuery($part_xpath, $part_args); + $i++; + } + $elements = $this->xpath($xpath); + $this->assertTrue(!empty($elements), 'Active trail to current page was found in menu tree.'); + + // Append prefix for active link asserted below. + $xpath .= '/following-sibling::ul/descendant::'; + } + else { + $xpath .= '//'; + } + $xpath_last_active = ($last_active ? 'and contains(@class, :class-active)' : ''); + $xpath .= 'li[contains(@class, :class-trail)]/a[contains(@href, :href) ' . $xpath_last_active . 'and contains(text(), :title)]'; + $args = array( + ':class-trail' => 'menu-item--active-trail', + ':class-active' => 'is-active', + ':href' => Url::fromUri('base:' . $active_link_path)->toString(), + ':title' => $active_link_title, + ); + $elements = $this->xpath($xpath, $args); + $this->assertTrue(!empty($elements), format_string('Active link %title was found in menu tree, including active trail links %tree.', array( + '%title' => $active_link_title, + '%tree' => implode(' » ', $tree), + ))); + } + +} diff --git a/core/modules/system/src/Tests/Menu/BreadcrumbTest.php b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php similarity index 99% rename from core/modules/system/src/Tests/Menu/BreadcrumbTest.php rename to core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php index f756300..4d5a023 100644 --- a/core/modules/system/src/Tests/Menu/BreadcrumbTest.php +++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php @@ -1,6 +1,6 @@ adminUser = $this->drupalCreateUser(array('access administration pages', 'administer modules')); + $this->drupalLogin($this->adminUser); + } + + /** + * Assert there are tables that begin with the specified base table name. + * + * @param $base_table + * Beginning of table name to look for. + * @param $count + * (optional) Whether or not to assert that there are tables that match the + * specified base table. Defaults to TRUE. + */ + function assertTableCount($base_table, $count = TRUE) { + $tables = db_find_tables(Database::getConnection()->prefixTables('{' . $base_table . '}') . '%'); + + if ($count) { + return $this->assertTrue($tables, format_string('Tables matching "@base_table" found.', array('@base_table' => $base_table))); + } + return $this->assertFalse($tables, format_string('Tables matching "@base_table" not found.', array('@base_table' => $base_table))); + } + + /** + * Assert that all tables defined in a module's hook_schema() exist. + * + * @param $module + * The name of the module. + */ + function assertModuleTablesExist($module) { + $tables = array_keys(drupal_get_module_schema($module)); + $tables_exist = TRUE; + foreach ($tables as $table) { + if (!db_table_exists($table)) { + $tables_exist = FALSE; + } + } + return $this->assertTrue($tables_exist, format_string('All database tables defined by the @module module exist.', array('@module' => $module))); + } + + /** + * Assert that none of the tables defined in a module's hook_schema() exist. + * + * @param $module + * The name of the module. + */ + function assertModuleTablesDoNotExist($module) { + $tables = array_keys(drupal_get_module_schema($module)); + $tables_exist = FALSE; + foreach ($tables as $table) { + if (db_table_exists($table)) { + $tables_exist = TRUE; + } + } + return $this->assertFalse($tables_exist, format_string('None of the database tables defined by the @module module exist.', array('@module' => $module))); + } + + /** + * Asserts that the default configuration of a module has been installed. + * + * @param string $module + * The name of the module. + * + * @return bool + * TRUE if configuration has been installed, FALSE otherwise. + */ + function assertModuleConfig($module) { + $module_config_dir = drupal_get_path('module', $module) . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY; + if (!is_dir($module_config_dir)) { + return; + } + $module_file_storage = new FileStorage($module_config_dir); + + // Verify that the module's default config directory is not empty and + // contains default configuration files (instead of something else). + $all_names = $module_file_storage->listAll(); + if (empty($all_names)) { + // Module has an empty config directory. For example it might contain a + // schema directory. + return; + } + $this->assertTrue($all_names); + + // Look up each default configuration object name in the active + // configuration, and if it exists, remove it from the stack. + // Only default config that belongs to $module is guaranteed to exist; any + // other default config depends on whether other modules are enabled. Thus, + // list all default config once more, but filtered by $module. + $names = $module_file_storage->listAll($module . '.'); + foreach ($names as $key => $name) { + if ($this->config($name)->get()) { + unset($names[$key]); + } + } + // Verify that all configuration has been installed (which means that $names + // is empty). + return $this->assertFalse($names, format_string('All default configuration of @module module found.', array('@module' => $module))); + } + + /** + * Asserts that no configuration exists for a given module. + * + * @param string $module + * The name of the module. + * + * @return bool + * TRUE if no configuration was found, FALSE otherwise. + */ + function assertNoModuleConfig($module) { + $names = \Drupal::configFactory()->listAll($module . '.'); + return $this->assertFalse($names, format_string('No configuration found for @module module.', array('@module' => $module))); + } + + /** + * Assert the list of modules are enabled or disabled. + * + * @param $modules + * Module list to check. + * @param $enabled + * Expected module state. + */ + function assertModules(array $modules, $enabled) { + $this->rebuildContainer(); + foreach ($modules as $module) { + if ($enabled) { + $message = 'Module "@module" is enabled.'; + } + else { + $message = 'Module "@module" is not enabled.'; + } + $this->assertEqual($this->container->get('module_handler')->moduleExists($module), $enabled, format_string($message, array('@module' => $module))); + } + } + + /** + * Verify a log entry was entered for a module's status change. + * + * @param $type + * The category to which this message belongs. + * @param $message + * The message to store in the log. Keep $message translatable + * by not concatenating dynamic values into it! Variables in the + * message should be added by using placeholder strings alongside + * the variables argument to declare the value of the placeholders. + * See t() for documentation on how $message and $variables interact. + * @param $variables + * Array of variables to replace in the message on display or + * NULL if message is already translated or not possible to + * translate. + * @param $severity + * The severity of the message, as per RFC 3164. + * @param $link + * A link to associate with the message. + */ + function assertLogMessage($type, $message, $variables = array(), $severity = RfcLogLevel::NOTICE, $link = '') { + $count = db_select('watchdog', 'w') + ->condition('type', $type) + ->condition('message', $message) + ->condition('variables', serialize($variables)) + ->condition('severity', $severity) + ->condition('link', $link) + ->countQuery() + ->execute() + ->fetchField(); + $this->assertTrue($count > 0, format_string('watchdog table contains @count rows for @message', array('@count' => $count, '@message' => format_string($message, $variables)))); + } + +} diff --git a/core/modules/system/src/Tests/Module/RequiredTest.php b/core/modules/system/tests/src/Functional/Module/RequiredTest.php similarity index 95% rename from core/modules/system/src/Tests/Module/RequiredTest.php rename to core/modules/system/tests/src/Functional/Module/RequiredTest.php index 004854b..6db8eac 100644 --- a/core/modules/system/src/Tests/Module/RequiredTest.php +++ b/core/modules/system/tests/src/Functional/Module/RequiredTest.php @@ -1,6 +1,6 @@ routes = $routes; + } + + /** + * Implements \Symfony\Cmf\Component\Routing\RouteProviderInterface::getRouteCollectionForRequest(). + * + * Simply return all routes to prevent + * \Symfony\Component\Routing\Exception\ResourceNotFoundException. + */ + public function getRouteCollectionForRequest(Request $request) { + return $this->routes; + } + + /** + * {@inheritdoc} + */ + public function getRouteByName($name) { + $routes = $this->getRoutesByNames(array($name)); + if (empty($routes)) { + throw new RouteNotFoundException(sprintf('Route "%s" does not exist.', $name)); + } + + return reset($routes); + } + + /** + * {@inheritdoc} + */ + public function preLoadRoutes($names) { + // Nothing to do. + } + + /** + * {@inheritdoc} + */ + public function getRoutesByNames($names) { + $routes = array(); + foreach ($names as $name) { + $routes[] = $this->routes->get($name); + } + + return $routes; + } + + /** + * {@inheritdoc} + */ + public function getRoutesByPattern($pattern) { + return new RouteCollection(); + } + + /** + * {@inheritdoc} + */ + public function getAllRoutes() { + return $this->routes->all(); + } + + /** + * {@inheritdoc} + */ + public function reset() { + $this->routes = array(); + } + +} diff --git a/core/modules/system/src/Tests/Routing/RouterPermissionTest.php b/core/modules/system/tests/src/Functional/Routing/RouterPermissionTest.php similarity index 86% rename from core/modules/system/src/Tests/Routing/RouterPermissionTest.php rename to core/modules/system/tests/src/Functional/Routing/RouterPermissionTest.php index 2fb0099..5bc0bb5 100644 --- a/core/modules/system/src/Tests/Routing/RouterPermissionTest.php +++ b/core/modules/system/tests/src/Functional/Routing/RouterPermissionTest.php @@ -1,15 +1,15 @@ drupalPlaceBlock('system_breadcrumb_block'); + + // Create Basic page and Article node types. + if ($this->profile != 'standard') { + $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); + } + } + +} diff --git a/core/modules/taxonomy/tests/src/Functional/TaxonomyTestTrait.php b/core/modules/taxonomy/tests/src/Functional/TaxonomyTestTrait.php new file mode 100644 index 0000000..b0eeb0d --- /dev/null +++ b/core/modules/taxonomy/tests/src/Functional/TaxonomyTestTrait.php @@ -0,0 +1,60 @@ + $this->randomMachineName(), + 'description' => $this->randomMachineName(), + 'vid' => Unicode::strtolower($this->randomMachineName()), + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + 'weight' => mt_rand(0, 10), + ]); + $vocabulary->save(); + return $vocabulary; + } + + /** + * Returns a new term with random properties in vocabulary $vid. + * + * @param \Drupal\taxonomy\Entity\Vocabulary $vocabulary + * The vocabulary object. + * @param array $values + * (optional) An array of values to set, keyed by property name. If the + * entity type has bundles, the bundle key has to be specified. + * + * @return \Drupal\taxonomy\Entity\Term + * The new taxonomy term object. + */ + function createTerm(Vocabulary $vocabulary, $values = array()) { + $filter_formats = filter_formats(); + $format = array_pop($filter_formats); + $term = Term::create($values + [ + 'name' => $this->randomMachineName(), + 'description' => [ + 'value' => $this->randomMachineName(), + // Use the first available text format. + 'format' => $format->id(), + ], + 'vid' => $vocabulary->id(), + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + ]); + $term->save(); + return $term; + } + +} diff --git a/core/modules/taxonomy/tests/src/Functional/TaxonomyTranslationTestTrait.php b/core/modules/taxonomy/tests/src/Functional/TaxonomyTranslationTestTrait.php new file mode 100644 index 0000000..0a40f79 --- /dev/null +++ b/core/modules/taxonomy/tests/src/Functional/TaxonomyTranslationTestTrait.php @@ -0,0 +1,105 @@ +translateToLangcode)->save(); + $this->rebuildContainer(); + } + + /** + * Enables translations where it needed. + */ + protected function enableTranslation() { + // Enable translation for the current entity type and ensure the change is + // picked up. + \Drupal::service('content_translation.manager')->setEnabled('node', 'article', TRUE); + \Drupal::service('content_translation.manager')->setEnabled('taxonomy_term', $this->vocabulary->id(), TRUE); + drupal_static_reset(); + \Drupal::entityManager()->clearCachedDefinitions(); + \Drupal::service('router.builder')->rebuild(); + \Drupal::service('entity.definition_update_manager')->applyUpdates(); + } + + /** + * Adds term reference field for the article content type. + * + * @param bool $translatable + * (optional) If TRUE, create a translatable term reference field. Defaults + * to FALSE. + */ + protected function setUpTermReferenceField() { + $handler_settings = array( + 'target_bundles' => array( + $this->vocabulary->id() => $this->vocabulary->id(), + ), + 'auto_create' => TRUE, + ); + $this->createEntityReferenceField('node', 'article', $this->termFieldName, NULL, 'taxonomy_term', 'default', $handler_settings, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + $field_storage = FieldStorageConfig::loadByName('node', $this->termFieldName); + $field_storage->setTranslatable(FALSE); + $field_storage->save(); + + entity_get_form_display('node', 'article', 'default') + ->setComponent($this->termFieldName, array( + 'type' => 'entity_reference_autocomplete_tags', + )) + ->save(); + entity_get_display('node', 'article', 'default') + ->setComponent($this->termFieldName, array( + 'type' => 'entity_reference_label', + )) + ->save(); + } + +} diff --git a/core/modules/taxonomy/src/Tests/TermCacheTagsTest.php b/core/modules/taxonomy/tests/src/Functional/TermCacheTagsTest.php similarity index 94% rename from core/modules/taxonomy/src/Tests/TermCacheTagsTest.php rename to core/modules/taxonomy/tests/src/Functional/TermCacheTagsTest.php index 8d7ddad..10f24e8 100644 --- a/core/modules/taxonomy/src/Tests/TermCacheTagsTest.php +++ b/core/modules/taxonomy/tests/src/Functional/TermCacheTagsTest.php @@ -1,6 +1,6 @@ mockStandardInstall(); + + if ($import_test_views) { + ViewTestData::createTestViews(get_class($this), array('taxonomy_test_views')); + } + + $this->term1 = $this->createTerm(); + $this->term2 = $this->createTerm(); + + $node = array(); + $node['type'] = 'article'; + $node['field_views_testing_tags'][]['target_id'] = $this->term1->id(); + $node['field_views_testing_tags'][]['target_id'] = $this->term2->id(); + $this->nodes[] = $this->drupalCreateNode($node); + $this->nodes[] = $this->drupalCreateNode($node); + } + + /** + * Provides a workaround for the inability to use the standard profile. + * + * @see https://www.drupal.org/node/1708692 + */ + protected function mockStandardInstall() { + $this->drupalCreateContentType(array( + 'type' => 'article', + )); + // Create the vocabulary for the tag field. + $this->vocabulary = Vocabulary::create([ + 'name' => 'Views testing tags', + 'vid' => 'views_testing_tags', + ]); + $this->vocabulary->save(); + $field_name = 'field_' . $this->vocabulary->id(); + + $handler_settings = array( + 'target_bundles' => array( + $this->vocabulary->id() => $this->vocabulary->id(), + ), + 'auto_create' => TRUE, + ); + $this->createEntityReferenceField('node', 'article', $field_name, 'Tags', 'taxonomy_term', 'default', $handler_settings, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + + entity_get_form_display('node', 'article', 'default') + ->setComponent($field_name, array( + 'type' => 'entity_reference_autocomplete_tags', + 'weight' => -4, + )) + ->save(); + + entity_get_display('node', 'article', 'default') + ->setComponent($field_name, array( + 'type' => 'entity_reference_label', + 'weight' => 10, + )) + ->save(); + entity_get_display('node', 'article', 'teaser') + ->setComponent($field_name, array( + 'type' => 'entity_reference_label', + 'weight' => 10, + )) + ->save(); + } + + /** + * Creates and returns a taxonomy term. + * + * @param array $settings + * (optional) An array of values to override the following default + * properties of the term: + * - name: A random string. + * - description: A random string. + * - format: First available text format. + * - vid: Vocabulary ID of self::$vocabulary object. + * - langcode: LANGCODE_NOT_SPECIFIED. + * Defaults to an empty array. + * + * @return \Drupal\taxonomy\Entity\Term + * The created taxonomy term. + */ + protected function createTerm(array $settings = []) { + $filter_formats = filter_formats(); + $format = array_pop($filter_formats); + $settings += [ + 'name' => $this->randomMachineName(), + 'description' => $this->randomMachineName(), + // Use the first available text format. + 'format' => $format->id(), + 'vid' => $this->vocabulary->id(), + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + ]; + $term = Term::create($settings); + $term->save(); + return $term; + } + +} diff --git a/core/modules/taxonomy/src/Tests/Views/TermNameFieldTest.php b/core/modules/taxonomy/tests/src/Functional/Views/TermNameFieldTest.php similarity index 96% rename from core/modules/taxonomy/src/Tests/Views/TermNameFieldTest.php rename to core/modules/taxonomy/tests/src/Functional/Views/TermNameFieldTest.php index 2effe72..32ffec0 100644 --- a/core/modules/taxonomy/src/Tests/Views/TermNameFieldTest.php +++ b/core/modules/taxonomy/tests/src/Functional/Views/TermNameFieldTest.php @@ -1,6 +1,6 @@ assertTourTips(); + * + * // Advanced example. The following would be used for multipage or + * // targeting a specific subset of tips. + * $tips = array(); + * $tips[] = array('data-id' => 'foo'); + * $tips[] = array('data-id' => 'bar'); + * $tips[] = array('data-class' => 'baz'); + * $this->assertTourTips($tips); + * @endcode + */ + public function assertTourTips($tips = array()) { + // Get the rendered tips and their data-id and data-class attributes. + if (empty($tips)) { + // Tips are rendered as
  • elements inside
      . + $rendered_tips = $this->xpath('//ol[@id = "tour"]//li[starts-with(@class, "tip")]'); + foreach ($rendered_tips as $rendered_tip) { + $attributes = (array) $rendered_tip->attributes(); + $tips[] = $attributes['@attributes']; + } + } + + // If the tips are still empty we need to fail. + if (empty($tips)) { + $this->fail('Could not find tour tips on the current page.'); + } + else { + // Check for corresponding page elements. + $total = 0; + $modals = 0; + foreach ($tips as $tip) { + if (!empty($tip['data-id'])) { + $elements = \PHPUnit_Util_XML::cssSelect('#' . $tip['data-id'], TRUE, $this->content, TRUE); + $this->assertTrue(!empty($elements) && count($elements) === 1, format_string('Found corresponding page element for tour tip with id #%data-id', array('%data-id' => $tip['data-id']))); + } + elseif (!empty($tip['data-class'])) { + $elements = \PHPUnit_Util_XML::cssSelect('.' . $tip['data-class'], TRUE, $this->content, TRUE); + $this->assertFalse(empty($elements), format_string('Found corresponding page element for tour tip with class .%data-class', array('%data-class' => $tip['data-class']))); + } + else { + // It's a modal. + $modals++; + } + $total++; + } + $this->pass(format_string('Total %total Tips tested of which %modals modal(s).', array('%total' => $total, '%modals' => $modals))); + } + } + +} diff --git a/core/modules/tour/src/Tests/TourTestBasic.php b/core/modules/tour/tests/src/Functional/TourTestBasic.php similarity index 97% rename from core/modules/tour/src/Tests/TourTestBasic.php rename to core/modules/tour/tests/src/Functional/TourTestBasic.php index e79eb2b..ec92987 100644 --- a/core/modules/tour/src/Tests/TourTestBasic.php +++ b/core/modules/tour/tests/src/Functional/TourTestBasic.php @@ -1,6 +1,6 @@ drupalPlaceBlock('system_breadcrumb_block'); + + $this->enableViewsTestModule(); + + $this->webUser = $this->drupalCreateUser(); + $roles = $this->webUser->getRoles(); + $this->webRole = $roles[0]; + + $this->normalRole = $this->drupalCreateRole(array()); + $this->normalUser = $this->drupalCreateUser(array('views_test_data test permission')); + $this->normalUser->addRole($this->normalRole); + $this->normalUser->save(); + // @todo when all the plugin information is cached make a reset function and + // call it here. + } + +} diff --git a/core/modules/user/src/Tests/Views/ArgumentDefaultTest.php b/core/modules/user/tests/src/Functional/Views/ArgumentDefaultTest.php similarity index 94% rename from core/modules/user/src/Tests/Views/ArgumentDefaultTest.php rename to core/modules/user/tests/src/Functional/Views/ArgumentDefaultTest.php index 4e21d79..325f99c 100644 --- a/core/modules/user/src/Tests/Views/ArgumentDefaultTest.php +++ b/core/modules/user/tests/src/Functional/Views/ArgumentDefaultTest.php @@ -1,6 +1,6 @@ users[] = $this->drupalCreateUser(); + $this->users[] = User::load(1); + $this->nodes[] = $this->drupalCreateNode(array('uid' => $this->users[0]->id())); + $this->nodes[] = $this->drupalCreateNode(array('uid' => 1)); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php b/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php index 5785a7d..2b51bce 100644 --- a/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php +++ b/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php @@ -2,7 +2,10 @@ namespace Drupal\FunctionalTests; +use Behat\Mink\Selector\Xpath\Escaper; use Drupal\KernelTests\AssertLegacyTrait as BaseAssertLegacyTrait; +use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Xss; /** * Provides convenience methods for assertions in browser tests. @@ -57,6 +60,9 @@ protected function assertElementNotPresent($css_selector) { * $this->assertSession()->responseContains() instead. */ protected function assertText($text) { + // Cast MarkupInterface to string. + $text = (string) $text; + $content_type = $this->getSession()->getResponseHeader('Content-type'); // In case of a Non-HTML response (example: XML) check the original // response. @@ -64,7 +70,7 @@ protected function assertText($text) { $this->assertSession()->responseContains($text); } else { - $this->assertSession()->pageTextContains($text); + $this->assertTextHelper($text, FALSE); } } @@ -82,6 +88,9 @@ protected function assertText($text) { * $this->assertSession()->responseNotContains() instead. */ protected function assertNoText($text) { + // Cast MarkupInterface to string. + $text = (string) $text; + $content_type = $this->getSession()->getResponseHeader('Content-type'); // In case of a Non-HTML response (example: XML) check the original // response. @@ -89,11 +98,41 @@ protected function assertNoText($text) { $this->assertSession()->responseNotContains($text); } else { - $this->assertSession()->pageTextNotContains($text); + $this->assertTextHelper($text); } } /** + * Helper for assertText and assertNoText. + * + * @param string $text + * Plain text to look for. + * @param bool $not_exists + * (optional) TRUE if this text should not exist, FALSE if it should. + * Defaults to TRUE. + * + * @return bool + * TRUE on pass, FALSE on fail. + */ + protected function assertTextHelper($text, $not_exists = TRUE) { + $args = ['@text' => $text]; + $message = $not_exists ? new FormattableMarkup('"@text" not found', $args) : new FormattableMarkup('"@text" found', $args); + + $raw_content = $this->getSession()->getPage()->getContent(); + // Trying to simulate what the user sees, given that it removes all text + // inside the head tags, removes inline Javascript, fix all HTML entities, + // removes dangerous protocols and filtering out all HTML tags, as they are + // not visible in a normal browser. + $raw_content = preg_replace('@(.+?)@si', '', $raw_content); + $page_text = Xss::filter($raw_content, []); + + $actual = $not_exists == (strpos($page_text, (string) $text) === FALSE); + $this->assertTrue($actual, $message); + + return $actual; + } + + /** * Passes if the text is found ONLY ONCE on the text version of the page. * * The text version is the equivalent of what a user would see when viewing @@ -464,6 +503,103 @@ protected function assertNoFieldChecked($id) { } /** + * Asserts that a field exists in the current page by the given XPath. + * + * @param string $xpath + * XPath used to find the field. + * @param string $value + * (optional) Value of the field to assert. You may pass in NULL (default) + * to skip checking the actual value, while still checking that the field + * exists. + * @param string $message + * (optional) A message to display with the assertion. Do not translate + * messages with t(). + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->xpath() instead and check the values directly in the test. + */ + protected function assertFieldByXPath($xpath, $value = NULL, $message = '') { + $fields = $this->xpath($xpath); + + $this->assertFieldsByValue($fields, $value, $message); + } + + /** + * Asserts that a field does not exist or its value does not match, by XPath. + * + * @param string $xpath + * XPath used to find the field. + * @param string $value + * (optional) Value of the field, to assert that the field's value on the + * page does not match it. + * @param string $message + * (optional) A message to display with the assertion. Do not translate + * messages with t(). + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->fieldNotExists() or + * $this->assertSession()->fieldValueNotEquals() instead. + */ + protected function assertNoFieldByXPath($xpath, $value = NULL, $message = '') { + $fields = $this->xpath($xpath); + + // If value specified then check array for match. + $found = TRUE; + if (isset($value)) { + $found = FALSE; + if ($fields) { + foreach ($fields as $field) { + if ($field->getAttribute('value') == $value) { + $found = TRUE; + } + } + } + } + return $this->assertFalse($fields && $found, $message); + } + + /** + * Asserts that a field exists in the current page with a given Xpath result. + * + * @param \Behat\Mink\Element\NodeElement[] $fields + * Xml elements. + * @param string $value + * (optional) Value of the field to assert. You may pass in NULL (default) to skip + * checking the actual value, while still checking that the field exists. + * @param string $message + * (optional) A message to display with the assertion. Do not translate + * messages with t(). + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Iterate over the fields yourself instead and directly check the values in + * the test. + */ + protected function assertFieldsByValue($fields, $value = NULL, $message = '') { + // If value specified then check array for match. + $found = TRUE; + if (isset($value)) { + $found = FALSE; + if ($fields) { + foreach ($fields as $field) { + if ($field->getAttribute('value') == $value) { + // Input element with correct value. + $found = TRUE; + } + elseif ($field->find('xpath', '//option[@value = ' . (new Escaper())->escapeLiteral($value) . ' and @selected = "selected"]')) { + // Select element with an option. + $found = TRUE; + } + elseif ($field->getText() == $value) { + // Text area with correct text. + $found = TRUE; + } + } + } + } + $this->assertTrue($fields && $found, $message); + } + + /** * Passes if the raw text IS found escaped on the loaded page, fail otherwise. * * Raw text refers to the raw HTML that the page generated. diff --git a/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php b/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php index e4979d3..9d72c00 100644 --- a/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php +++ b/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php @@ -2,6 +2,7 @@ namespace Drupal\FunctionalTests; +use Drupal\Component\Utility\Html; use Drupal\Core\Url; use Drupal\Tests\BrowserTestBase; @@ -86,4 +87,33 @@ public function testError() { $this->drupalGet('test-error'); } + /** + * Tests that legacy assertions work. + */ + public function testAssertions() { + $account = $this->drupalCreateUser(['administer users'], 'test'); + $this->drupalLogin($account); + + $this->drupalGet('admin/people'); + $this->assertFieldByXpath('//table/tbody/tr[2]/td[1]/span', $account->getAccountName()); + + $this->drupalGet('user/' . $account->id() . '/edit'); + $this->assertFieldByXpath("//input[@id = 'edit-name']", $account->getAccountName()); + $this->assertFieldByXpath("//select[@id = 'edit-timezone--2']", 'Australia/Sydney'); + + $this->assertNoFieldByXPath('//notexisting'); + $this->assertNoFieldByXpath("//input[@id = 'edit-name']", 'wrong value'); + } + + /** + * Tests legacy asserts. + */ + public function testLegacyAsserts() { + $this->drupalGet('test-encoded'); + $dangerous = 'Bad html '; + $sanitized = Html::escape($dangerous); + $this->assertNoText($dangerous); + $this->assertText($sanitized); + } + } diff --git a/core/tests/Drupal/Tests/BrowserTestBase.php b/core/tests/Drupal/Tests/BrowserTestBase.php index 7939069..deb2ac5 100644 --- a/core/tests/Drupal/Tests/BrowserTestBase.php +++ b/core/tests/Drupal/Tests/BrowserTestBase.php @@ -21,6 +21,7 @@ use Drupal\Core\Session\UserSession; use Drupal\Core\Site\Settings; use Drupal\Core\StreamWrapper\StreamWrapperInterface; +use Drupal\Core\Test\AssertMailTrait; use Drupal\Core\Test\TestRunnerKernel; use Drupal\Core\Url; use Drupal\Core\Test\TestDatabase; @@ -63,6 +64,10 @@ createUser as drupalCreateUser; } + use AssertMailTrait { + getMails as drupalGetMails; + } + /** * Class loader. * @@ -932,7 +937,9 @@ protected function submitForm(array $edit, $submit, $form_html_id = NULL) { * @param array $options * Options to be forwarded to the url generator. */ - protected function drupalPostForm($path, array $edit, $submit, array $options = array()) { + protected function drupalPostForm($path, $edit, $submit, array $options = array()) { + $edit = (array) $edit; + if (is_object($submit)) { // Cast MarkupInterface objects to string. $submit = (string) $submit;