'Search engine queries', 'description' => 'Indexes content and queries it.', 'group' => 'Search', ); } /** * Implementation setUp(). */ function setUp() { parent::setUp('search'); } /** * Test search indexing. */ function testMatching() { $this->_setup(); $this->_testQueries(); } /** * Set up a small index of items to test against. */ function _setup() { variable_set('minimum_word_size', 3); for ($i = 1; $i <= 7; ++$i) { search_index($i, SEARCH_TYPE, $this->getText($i)); } for ($i = 1; $i <= 5; ++$i) { search_index($i + 7, SEARCH_TYPE_2, $this->getText2($i)); } // No getText builder function for Japanese text; just a simple array. foreach (array( 13 => '以呂波耳・ほへとち。リヌルヲ。', 14 => 'ドルーパルが大好きよ!', 15 => 'コーヒーとケーキ', ) as $i => $jpn) { search_index($i, SEARCH_TYPE_JPN, $jpn); } search_update_totals(); } /** * _test_: Helper method for generating snippets of content. * * Generated items to test against: * 1 ipsum * 2 dolore sit * 3 sit am ut * 4 am ut enim am * 5 ut enim am minim veniam * 6 enim am minim veniam es cillum * 7 am minim veniam es cillum dolore eu */ function getText($n) { $words = explode(' ', "Ipsum dolore sit am. Ut enim am minim veniam. Es cillum dolore eu."); return implode(' ', array_slice($words, $n - 1, $n)); } /** * _test2_: Helper method for generating snippets of content. * * Generated items to test against: * 8 dear * 9 king philip * 10 philip came over * 11 came over from germany * 12 over from germany swimming */ function getText2($n) { $words = explode(' ', "Dear King Philip came over from Germany swimming."); return implode(' ', array_slice($words, $n - 1, $n)); } /** * Run predefine queries looking for indexed terms. */ function _testQueries() { /* Note: OR queries that include short words in OR groups are only accepted if the ORed terms are ANDed with at least one long word in the rest of the query. e.g. enim dolore OR ut = enim (dolore OR ut) = (enim dolor) OR (enim ut) -> good e.g. dolore OR ut = (dolore) OR (ut) -> bad This is a design limitation to avoid full table scans. */ $queries = array( // Simple AND queries. 'ipsum' => array(1), 'enim' => array(4, 5, 6), 'xxxxx' => array(), 'enim minim' => array(5, 6), 'enim xxxxx' => array(), 'dolore eu' => array(7), 'dolore xx' => array(), 'ut minim' => array(5), 'xx minim' => array(), 'enim veniam am minim ut' => array(5), // Simple OR queries. 'dolore OR ipsum' => array(1, 2, 7), 'dolore OR xxxxx' => array(2, 7), 'dolore OR ipsum OR enim' => array(1, 2, 4, 5, 6, 7), 'ipsum OR dolore sit OR cillum' => array(2, 7), 'minim dolore OR ipsum' => array(7), 'dolore OR ipsum veniam' => array(7), 'minim dolore OR ipsum OR enim' => array(5, 6, 7), 'dolore xx OR yy' => array(), 'xxxxx dolore OR ipsum' => array(), // Negative queries. 'dolore -sit' => array(7), 'dolore -eu' => array(2), 'dolore -xxxxx' => array(2, 7), 'dolore -xx' => array(2, 7), // Phrase queries. '"dolore sit"' => array(2), '"sit dolore"' => array(), '"am minim veniam es"' => array(6, 7), '"minim am veniam es"' => array(), // Mixed queries. '"am minim veniam es" OR dolore' => array(2, 6, 7), '"minim am veniam es" OR "dolore sit"' => array(2), '"minim am veniam es" OR "sit dolore"' => array(), '"am minim veniam es" -eu' => array(6), '"am minim veniam" -"cillum dolore"' => array(5, 6), '"am minim veniam" -"dolore cillum"' => array(5, 6, 7), 'xxxxx "minim am veniam es" OR dolore' => array(), 'xx "minim am veniam es" OR dolore' => array() ); foreach ($queries as $query => $results) { $result = db_select('search_index', 'i') ->extend('SearchQuery') ->searchExpression($query, SEARCH_TYPE) ->execute(); $set = $result ? $result->fetchAll() : array(); $this->_testQueryMatching($query, $set, $results); $this->_testQueryScores($query, $set, $results); } // These queries are run against the second index type, SEARCH_TYPE_2. $queries = array( // Simple AND queries. 'ipsum' => array(), 'enim' => array(), 'enim minim' => array(), 'dear' => array(8), 'germany' => array(11, 12), ); foreach ($queries as $query => $results) { $result = db_select('search_index', 'i') ->extend('SearchQuery') ->searchExpression($query, SEARCH_TYPE_2) ->execute(); $set = $result ? $result->fetchAll() : array(); $this->_testQueryMatching($query, $set, $results); $this->_testQueryScores($query, $set, $results); } // These queries are run against the third index type, SEARCH_TYPE_JPN. $queries = array( // Simple AND queries. '呂波耳' => array(13), '以呂波耳' => array(13), 'ほへと ヌルヲ' => array(13), 'とちリ' => array(), 'ドルーパル' => array(14), 'パルが大' => array(14), 'コーヒー' => array(15), 'ヒーキ' => array(), ); foreach ($queries as $query => $results) { $result = db_select('search_index', 'i') ->extend('SearchQuery') ->searchExpression($query, SEARCH_TYPE_JPN) ->execute(); $set = $result ? $result->fetchAll() : array(); $this->_testQueryMatching($query, $set, $results); $this->_testQueryScores($query, $set, $results); } } /** * Test the matching abilities of the engine. * * Verify if a query produces the correct results. */ function _testQueryMatching($query, $set, $results) { // Get result IDs. $found = array(); foreach ($set as $item) { $found[] = $item->sid; } // Compare $results and $found. sort($found); sort($results); $this->assertEqual($found, $results, "Query matching '$query'"); } /** * Test the scoring abilities of the engine. * * Verify if a query produces normalized, monotonous scores. */ function _testQueryScores($query, $set, $results) { // Get result scores. $scores = array(); foreach ($set as $item) { $scores[] = $item->calculated_score; } // Check order. $sorted = $scores; sort($sorted); $this->assertEqual($scores, array_reverse($sorted), "Query order '$query'"); // Check range. $this->assertEqual(!count($scores) || (min($scores) > 0.0 && max($scores) <= 1.0001), TRUE, "Query scoring '$query'"); } } class SearchBikeShed extends DrupalWebTestCase { protected $searching_user; public static function getInfo() { return array( 'name' => 'Bike shed', 'description' => 'Tests the bike shed text on the no results page.', 'group' => 'Search' ); } function setUp() { parent::setUp('search'); // Create user. $this->searching_user = $this->drupalCreateUser(array('search content')); } function testFailedSearch() { $this->drupalLogin($this->searching_user); $this->drupalGet('search/node'); $this->assertText(t('Enter your keywords')); $edit = array(); $edit['keys'] = 'bike shed ' . $this->randomName(); $this->drupalPost('search/node', $edit, t('Search')); $this->assertText(t('Consider loosening your query with OR. bike OR shed will often show more results than bike shed.'), t('Help text is displayed when search returns no results.')); } } class SearchAdvancedSearchForm extends DrupalWebTestCase { protected $node; public static function getInfo() { return array( 'name' => 'Advanced search form', 'description' => 'Indexes content and tests the advanced search form.', 'group' => 'Search', ); } function setUp() { parent::setUp('search'); // Create and login user. $test_user = $this->drupalCreateUser(array('access content', 'search content', 'use advanced search', 'administer nodes')); $this->drupalLogin($test_user); // Create initial node. $node = $this->drupalCreateNode(); $this->node = $this->drupalCreateNode(); // First update the index. This does the initial processing. node_update_index(); // Then, run the shutdown function. Testing is a unique case where indexing // and searching has to happen in the same request, so running the shutdown // function manually is needed to finish the indexing process. search_update_totals(); } /** * Test using the search form with GET and POST queries. * Test using the advanced search form to limit search to nodes of type "Basic page". */ function testNodeType() { $this->assertTrue($this->node->type == 'page', t('Node type is Basic page.')); // Assert that the dummy title doesn't equal the real title. $dummy_title = 'Lorem ipsum'; $this->assertNotEqual($dummy_title, $this->node->title, t("Dummy title doens't equal node title")); // Search for the dummy title with a GET query. $this->drupalGet('search/node/' . $dummy_title); $this->assertNoText($this->node->title, t('Basic page node is not found with dummy title.')); // Search for the title of the node with a GET query. $this->drupalGet('search/node/' . $this->node->title); $this->assertText($this->node->title, t('Basic page node is found with GET query.')); // Search for the title of the node with a POST query. $edit = array('or' => $this->node->title); $this->drupalPost('search/node', $edit, t('Advanced search')); $this->assertText($this->node->title, t('Basic page node is found with POST query.')); // Advanced search type option. $this->drupalPost('search/node', array_merge($edit, array('type[page]' => 'page')), t('Advanced search')); $this->assertText($this->node->title, t('Basic page node is found with POST query and type:page.')); $this->drupalPost('search/node', array_merge($edit, array('type[article]' => 'article')), t('Advanced search')); $this->assertText('bike shed', t('Article node is not found with POST query and type:article.')); } } class SearchRankingTestCase extends DrupalWebTestCase { public static function getInfo() { return array( 'name' => 'Search engine ranking', 'description' => 'Indexes content and tests ranking factors.', 'group' => 'Search', ); } /** * Implementation setUp(). */ function setUp() { parent::setUp('search', 'statistics', 'comment'); } function testRankings() { // Login with sufficient privileges. $this->drupalLogin($this->drupalCreateUser(array('post comments without approval', 'create page content'))); // Build a list of the rankings to test. $node_ranks = array('sticky', 'promote', 'relevance', 'recent', 'comments', 'views'); // Create nodes for testing. foreach ($node_ranks as $node_rank) { $settings = array('type' => 'page', 'title' => array(LANGUAGE_NONE => array(array('value' => 'Drupal rocks'))), 'body' => array(LANGUAGE_NONE => array(array('value' => "Drupal's search rocks")))); foreach (array(0, 1) as $num) { if ($num == 1) { switch ($node_rank) { case 'sticky': case 'promote': $settings[$node_rank] = 1; break; case 'relevance': $settings['body'][LANGUAGE_NONE][0]['value'] .= " really rocks"; break; case 'recent': $settings['created'] = REQUEST_TIME + 3600; break; case 'comments': $settings['comment'] = 2; break; } } $nodes[$node_rank][$num] = $this->drupalCreateNode($settings); } } // Update the search index. module_invoke_all('update_index'); search_update_totals(); // Refresh variables after the treatment. $this->refreshVariables(); // Add a comment to one of the nodes. $edit = array(); $edit['subject'] = 'my comment title'; $edit['comment_body[' . LANGUAGE_NONE . '][0][value]'] = 'some random comment'; $this->drupalGet('comment/reply/' . $nodes['comments'][1]->nid); $this->drupalPost(NULL, $edit, t('Preview')); $this->drupalPost(NULL, $edit, t('Save')); // Enable counting of statistics. variable_set('statistics_count_content_views', 1); // Then View one of the nodes a bunch of times. for ($i = 0; $i < 5; $i ++) { $this->drupalGet('node/' . $nodes['views'][1]->nid); } // Test each of the possible rankings. foreach ($node_ranks as $node_rank) { // Disable all relevancy rankings except the one we are testing. foreach ($node_ranks as $var) { variable_set('node_rank_' . $var, $var == $node_rank ? 10 : 0); } // Do the search and assert the results. $set = node_search_execute('rocks'); $this->assertEqual($set[0]['node']->nid, $nodes[$node_rank][1]->nid, 'Search ranking "' . $node_rank . '" order.'); } } /** * Test rankings of HTML tags. */ function testHTMLRankings() { // Login with sufficient privileges. $this->drupalLogin($this->drupalCreateUser(array('create page content'))); // Test HTML tags with different weights. $sorted_tags = array('h1', 'h2', 'h3', 'h4', 'a', 'h5', 'h6', 'notag'); $shuffled_tags = $sorted_tags; // Shuffle tags to ensure HTML tags are ranked properly. shuffle($shuffled_tags); $settings = array( 'type' => 'page', 'title' => array(LANGUAGE_NONE => array(array('value' => 'Simple node'))), ); foreach ($shuffled_tags as $tag) { switch ($tag) { case 'a': $settings['body'] = array(LANGUAGE_NONE => array(array('value' => l('Drupal Rocks', 'node'), 'format' => 3))); break; case 'notag': $settings['body'] = array(LANGUAGE_NONE => array(array('value' => 'Drupal Rocks'))); break; default: $settings['body'] = array(LANGUAGE_NONE => array(array('value' => "<$tag>Drupal Rocks$tag>", 'format' => 3))); break; } $nodes[$tag] = $this->drupalCreateNode($settings); } // Update the search index. module_invoke_all('update_index'); search_update_totals(); // Refresh variables after the treatment. $this->refreshVariables(); // Disable all other rankings. $node_ranks = array('sticky', 'promote', 'recent', 'comments', 'views'); foreach ($node_ranks as $node_rank) { variable_set('node_rank_' . $node_rank, 0); } $set = node_search_execute('rocks'); // Test the ranking of each tag. foreach ($sorted_tags as $tag_rank => $tag) { // Assert the results. if ($tag == 'notag') { $this->assertEqual($set[$tag_rank]['node']->nid, $nodes[$tag]->nid, 'Search tag ranking for plain text order.'); } else { $this->assertEqual($set[$tag_rank]['node']->nid, $nodes[$tag]->nid, 'Search tag ranking for "<' . $sorted_tags[$tag_rank] . '>" order.'); } } // Test tags with the same weight against the sorted tags. $unsorted_tags = array('u', 'b', 'i', 'strong', 'em'); foreach ($unsorted_tags as $tag) { $settings['body'] = array(LANGUAGE_NONE => array(array('value' => "<$tag>Drupal Rocks$tag>", 'format' => 3))); $node = $this->drupalCreateNode($settings); // Update the search index. module_invoke_all('update_index'); search_update_totals(); // Refresh variables after the treatment. $this->refreshVariables(); $set = node_search_execute('rocks'); // Ranking should always be second to last. $set = array_slice($set, -2, 1); // Assert the results. $this->assertEqual($set[0]['node']->nid, $node->nid, 'Search tag ranking for "<' . $tag . '>" order.'); // Delete node so it doesn't show up in subsequent search results. node_delete($node->nid); } } /** * Verifies that if we combine two rankings, search still works. * * See issue http://drupal.org/node/771596 */ function testDoubleRankings() { // Login with sufficient privileges. $this->drupalLogin($this->drupalCreateUser(array('post comments without approval', 'create page content'))); // See testRankings() above - build a node that will rank high for sticky. $settings = array( 'type' => 'page', 'title' => array(LANGUAGE_NONE => array(array('value' => 'Drupal rocks'))), 'body' => array(LANGUAGE_NONE => array(array('value' => "Drupal's search rocks"))), 'sticky' => 1, ); $node = $this->drupalCreateNode($settings); // Update the search index. module_invoke_all('update_index'); search_update_totals(); // Refresh variables after the treatment. $this->refreshVariables(); // Set up for ranking sticky and lots of comments; make sure others are // disabled. $node_ranks = array('sticky', 'promote', 'relevance', 'recent', 'comments', 'views'); foreach ($node_ranks as $var) { $value = ($var == 'sticky' || $var == 'comments') ? 10 : 0; variable_set('node_rank_' . $var, $value); } // Do the search and assert the results. $set = node_search_execute('rocks'); $this->assertEqual($set[0]['node']->nid, $node->nid, 'Search double ranking order.'); } } class SearchBlockTestCase extends DrupalWebTestCase { public static function getInfo() { return array( 'name' => 'Block availability', 'description' => 'Check if the search form block is available.', 'group' => 'Search', ); } function setUp() { parent::setUp('search'); // Create and login user $admin_user = $this->drupalCreateUser(array('administer blocks', 'search content')); $this->drupalLogin($admin_user); } function testSearchFormBlock() { // Set block title to confirm that the interface is availble. $this->drupalPost('admin/structure/block/manage/search/form/configure', array('title' => $this->randomName(8)), t('Save block')); $this->assertText(t('The block configuration has been saved.'), t('Block configuration set.')); // Set the block to a region to confirm block is availble. $edit = array(); $edit['search_form[region]'] = 'footer'; $this->drupalPost('admin/structure/block', $edit, t('Save blocks')); $this->assertText(t('The block settings have been updated.'), t('Block successfully move to footer region.')); } /** * Test that the search block form works correctly. */ function testBlock() { // Enable the block, and place it in the 'content' region so that it isn't // hidden on 404 pages. $edit = array('search_form[region]' => 'content'); $this->drupalPost('admin/structure/block', $edit, t('Save blocks')); // Test a normal search via the block form, from the front page. $terms = array('search_block_form' => 'test'); $this->drupalPost('node', $terms, t('Search')); $this->assertText('Your search yielded no results'); // Test a search from the block on a 404 page. $this->drupalGet('foo'); $this->assertResponse(404); $this->drupalPost(NULL, $terms, t('Search')); $this->assertResponse(200); $this->assertText('Your search yielded no results'); // Test a search from the block when it doesn't appear on the search page. $edit = array('pages' => 'search'); $this->drupalPost('admin/structure/block/manage/search/form/configure', $edit, t('Save block')); $this->drupalPost('node', $terms, t('Search')); $this->assertText('Your search yielded no results'); } } /** * Tests that searching for a phrase gets the correct page count. */ class SearchExactTestCase extends DrupalWebTestCase { public static function getInfo() { return array( 'name' => 'Search engine phrase queries', 'description' => 'Tests that searching for a phrase gets the correct page count.', 'group' => 'Search', ); } function setUp() { parent::setUp('search'); } /** * Tests that the correct number of pager links are found for both keywords and phrases. */ function testExactQuery() { // Login with sufficient privileges. $this->drupalLogin($this->drupalCreateUser(array('create page content', 'search content'))); $settings = array( 'type' => 'page', 'title' => 'Simple Node', ); // Create nodes with exact phrase. for ($i = 0; $i <= 17; $i++) { $settings['body'] = array(LANGUAGE_NONE => array(array('value' => 'love pizza'))); $this->drupalCreateNode($settings); } // Create nodes containing keywords. for ($i = 0; $i <= 17; $i++) { $settings['body'] = array(LANGUAGE_NONE => array(array('value' => 'love cheesy pizza'))); $this->drupalCreateNode($settings); } // Update the search index. module_invoke_all('update_index'); search_update_totals(); // Refresh variables after the treatment. $this->refreshVariables(); // Test that the correct number of pager links are found for keyword search. $edit = array('keys' => 'love pizza'); $this->drupalPost('search/node', $edit, t('Search')); $this->assertLinkByHref('page=1', 0, '2nd page link is found for keyword search.'); $this->assertLinkByHref('page=2', 0, '3rd page link is found for keyword search.'); $this->assertLinkByHref('page=3', 0, '4th page link is found for keyword search.'); $this->assertNoLinkByHref('page=4', '5th page link is not found for keyword search.'); // Test that the correct number of pager links are found for exact phrase search. $edit = array('keys' => '"love pizza"'); $this->drupalPost('search/node', $edit, t('Search')); $this->assertLinkByHref('page=1', 0, '2nd page link is found for exact phrase search.'); $this->assertNoLinkByHref('page=2', '3rd page link is not found for exact phrase search.'); } } /** * Test integration searching comments. */ class SearchCommentTestCase extends DrupalWebTestCase { protected $admin_user; public static function getInfo() { return array( 'name' => 'Comment Search tests', 'description' => 'Verify text formats and filters used elsewhere.', 'group' => 'Search', ); } function setUp() { parent::setUp('comment', 'search'); // Create and log in an administrative user having access to the Full HTML // text format. $full_html_format = db_query_range('SELECT * FROM {filter_format} WHERE name = :name', 0, 1, array(':name' => 'Full HTML'))->fetchObject(); $this->admin_user = $this->drupalCreateUser(array( 'administer filters', filter_permission_name($full_html_format), 'administer permissions', 'create page content', 'post comments without approval', )); $this->drupalLogin($this->admin_user); } /** * Verify that comments are rendered using proper format in search results. */ function testSearchResultsComment() { $comment_body = 'Test comment body'; variable_set('comment_preview_article', DRUPAL_OPTIONAL); // Enable check_plain() for 'Filtered HTML' text format. $filtered_html_format_id = db_query_range('SELECT format FROM {filter_format} WHERE name = :name', 0, 1, array(':name' => 'Filtered HTML'))->fetchField(); $edit = array( 'filters[filter_html_escape][status]' => $filtered_html_format_id, ); $this->drupalPost('admin/config/content/formats/1', $edit, t('Save configuration')); // Allow anonymous users to search content. $edit = array( DRUPAL_ANONYMOUS_RID . '[search content]' => 1, // @todo Comments are added to search index without checking first whether // anonymous users are allowed to access comments. DRUPAL_ANONYMOUS_RID . '[access comments]' => 1, // @todo Without this permission, "Login or register to post comments" is // added to the search index. Comment.module is not guilty; that text // seems to be added via node links. DRUPAL_ANONYMOUS_RID . '[post comments]' => 1, ); $this->drupalPost('admin/people/permissions', $edit, t('Save permissions')); // Create a node. $node = $this->drupalCreateNode(array('type' => 'article')); // Post a comment using 'Full HTML' text format. $edit_comment = array(); $edit_comment['subject'] = 'Test comment subject'; $edit_comment['comment_body[' . LANGUAGE_NONE . '][0][value]'] = '