. /** * Solr earch engine base unit tests. * * Required params: * - define('TEST_SEARCH_SOLR_HOSTNAME', '127.0.0.1'); * - define('TEST_SEARCH_SOLR_PORT', '8983'); * - define('TEST_SEARCH_SOLR_INDEXNAME', 'unittest'); * * Optional params: * - define('TEST_SEARCH_SOLR_USERNAME', ''); * - define('TEST_SEARCH_SOLR_PASSWORD', ''); * - define('TEST_SEARCH_SOLR_SSLCERT', ''); * - define('TEST_SEARCH_SOLR_SSLKEY', ''); * - define('TEST_SEARCH_SOLR_KEYPASSWORD', ''); * - define('TEST_SEARCH_SOLR_CAINFOCERT', ''); * * @package search_solr * @category phpunit * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); require_once($CFG->dirroot . '/search/engine/solr/tests/fixtures/testable_engine.php'); /** * Solr search engine base unit tests. * * @package search_solr * @category phpunit * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class search_solr_engine_testcase extends advanced_testcase { /** * @var \core_search\manager */ protected $search = null; /** * @var Instace of core_search_generator. */ protected $generator = null; /** * @var Instace of testable_engine. */ protected $engine = null; public function setUp() { $this->resetAfterTest(); set_config('enableglobalsearch', true); set_config('searchengine', 'solr'); if (!function_exists('solr_get_version')) { $this->markTestSkipped('Solr extension is not loaded.'); } if (!defined('TEST_SEARCH_SOLR_HOSTNAME') || !defined('TEST_SEARCH_SOLR_INDEXNAME') || !defined('TEST_SEARCH_SOLR_PORT')) { $this->markTestSkipped('Solr extension test server not set.'); } set_config('server_hostname', TEST_SEARCH_SOLR_HOSTNAME, 'search_solr'); set_config('server_port', TEST_SEARCH_SOLR_PORT, 'search_solr'); set_config('indexname', TEST_SEARCH_SOLR_INDEXNAME, 'search_solr'); if (defined('TEST_SEARCH_SOLR_USERNAME')) { set_config('server_username', TEST_SEARCH_SOLR_USERNAME, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_PASSWORD')) { set_config('server_password', TEST_SEARCH_SOLR_PASSWORD, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_SSLCERT')) { set_config('secure', true, 'search_solr'); set_config('ssl_cert', TEST_SEARCH_SOLR_SSLCERT, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_SSLKEY')) { set_config('ssl_key', TEST_SEARCH_SOLR_SSLKEY, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_KEYPASSWORD')) { set_config('ssl_keypassword', TEST_SEARCH_SOLR_KEYPASSWORD, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_CAINFOCERT')) { set_config('ssl_cainfo', TEST_SEARCH_SOLR_CAINFOCERT, 'search_solr'); } set_config('fileindexing', 1, 'search_solr'); // We are only test indexing small string files, so setting this as low as we can. set_config('maxindexfilekb', 1, 'search_solr'); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); // Inject search solr engine into the testable core search as we need to add the mock // search component to it. $this->engine = new \search_solr\testable_engine(); $this->search = testable_core_search::instance($this->engine); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->add_search_area($areaid, new core_mocksearch\search\mock_search_area()); $this->setAdminUser(); // Cleanup before doing anything on it as the index it is out of this test control. $this->search->delete_index(); // Add moodle fields if they don't exist. $schema = new \search_solr\schema(); $schema->setup(false); } public function tearDown() { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Simple data provider to allow tests to be run with file indexing on and off. */ public function file_indexing_provider() { return array( 'file-indexing-on' => array(1), 'file-indexing-off' => array(0) ); } public function test_connection() { $this->assertTrue($this->engine->is_server_ready()); } /** * @dataProvider file_indexing_provider */ public function test_index($fileindexing) { global $DB; $this->engine->test_set_config('fileindexing', $fileindexing); $record = new \stdClass(); $record->timemodified = time() - 1; $this->generator->create_record($record); // Data gets into the search engine. $this->assertTrue($this->search->index()); // Not anymore as everything was already added. sleep(1); $this->assertFalse($this->search->index()); $this->generator->create_record(); // Indexing again once there is new data. $this->assertTrue($this->search->index()); } /** * Better keep this not very strict about which or how many results are returned as may depend on solr engine config. * * @dataProvider file_indexing_provider * * @return void */ public function test_search($fileindexing) { global $USER, $DB; $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $record = new \stdClass(); $record->title = "Special title"; $this->generator->create_record($record); $this->search->index(); $querydata = new stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(2, $results); // Based on core_mocksearch\search\indexer. $this->assertEquals($USER->id, $results[0]->get('userid')); $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid')); // Do a test to make sure we aren't searching non-query fields, like areaid. $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->assertCount(0, $this->search->search($querydata)); $querydata->q = 'message'; sleep(1); $beforeadding = time(); sleep(1); $this->generator->create_record(); $this->search->index(); // Timestart. $querydata->timestart = $beforeadding; $this->assertCount(1, $this->search->search($querydata)); // Timeend. unset($querydata->timestart); $querydata->timeend = $beforeadding; $this->assertCount(2, $this->search->search($querydata)); // Title. unset($querydata->timeend); $querydata->title = 'Special title'; $this->assertCount(1, $this->search->search($querydata)); // Course IDs. unset($querydata->title); $querydata->courseids = array(SITEID + 1); $this->assertCount(0, $this->search->search($querydata)); $querydata->courseids = array(SITEID); $this->assertCount(3, $this->search->search($querydata)); // Now try some area-id combinations. unset($querydata->courseids); $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $querydata->areaids = array($forumpostareaid); $this->assertCount(0, $this->search->search($querydata)); $querydata->areaids = array($forumpostareaid, $mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array($mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array(); $this->assertCount(3, $this->search->search($querydata)); // Check that index contents get updated. $this->generator->delete_all(); $this->search->index(true); unset($querydata->title); $querydata->q = '*'; $this->assertCount(0, $this->search->search($querydata)); } /** * @dataProvider file_indexing_provider */ public function test_delete($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); $querydata = new stdClass(); $querydata->q = 'message'; $this->assertCount(2, $this->search->search($querydata)); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->delete_index($areaid); $this->assertCount(0, $this->search->search($querydata)); } /** * @dataProvider file_indexing_provider */ public function test_alloweduserid($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $area = new core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); // Get the doc and insert the default doc. $doc = $area->get_document($record); $this->engine->add_document($doc); $users = array(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); // Add a record that only user 100 can see. $originalid = $doc->get('id'); // Now add a custom doc for each user. foreach ($users as $user) { $doc = $area->get_document($record); $doc->set('id', $originalid.'-'.$user->id); $doc->set('owneruserid', $user->id); $this->engine->add_document($doc); } $this->engine->area_index_complete($area->get_area_id()); $querydata = new stdClass(); $querydata->q = 'message'; $querydata->title = $doc->get('title'); // We are going to go through each user and see if they get the original and the owned doc. foreach ($users as $user) { $this->setUser($user); $results = $this->search->search($querydata); $this->assertCount(2, $results); $owned = 0; $notowned = 0; // We don't know what order we will get the results in, so we are doing this. foreach ($results as $result) { $owneruserid = $result->get('owneruserid'); if (empty($owneruserid)) { $notowned++; $this->assertEquals(0, $owneruserid); $this->assertEquals($originalid, $result->get('id')); } else { $owned++; $this->assertEquals($user->id, $owneruserid); $this->assertEquals($originalid.'-'.$user->id, $result->get('id')); } } $this->assertEquals(1, $owned); $this->assertEquals(1, $notowned); } // Now test a user with no owned results. $otheruser = $this->getDataGenerator()->create_user(); $this->setUser($otheruser); $results = $this->search->search($querydata); $this->assertCount(1, $results); $this->assertEquals(0, $results[0]->get('owneruserid')); $this->assertEquals($originalid, $results[0]->get('id')); } /** * @dataProvider file_indexing_provider */ public function test_highlight($fileindexing) { global $PAGE; $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $this->search->index(); $querydata = new stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $regex = '|'.\search_solr\engine::HIGHLIGHT_START.'message'.\search_solr\engine::HIGHLIGHT_END.'|'; $this->assertRegExp($regex, $result->get('content')); $searchrenderer = $PAGE->get_renderer('core_search'); $exported = $result->export_for_template($searchrenderer); $regex = '|message|'; $this->assertRegExp($regex, $exported['content']); } public function test_export_file_for_engine() { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); $record = $this->generator->create_record(); $doc = $area->get_document($record); $filerecord = new stdClass(); $filerecord->timemodified = 978310800; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $filearray = $doc->export_file_for_engine($file); $this->assertEquals(\core_search\manager::TYPE_FILE, $filearray['type']); $this->assertEquals($file->get_id(), $filearray['solr_fileid']); $this->assertEquals($file->get_contenthash(), $filearray['solr_filecontenthash']); $this->assertEquals(\search_solr\document::INDEXED_FILE_TRUE, $filearray['solr_fileindexstatus']); $this->assertEquals($file->get_filename(), $filearray['title']); $this->assertEquals(978310800, \search_solr\document::import_time_from_engine($filearray['modified'])); } public function test_index_file() { // Very simple test. $file = $this->generator->create_file(); $record = new \stdClass(); $record->attachfileids = array($file->get_id()); $this->generator->create_record($record); $this->search->index(); $querydata = new stdClass(); $querydata->q = '"File contents"'; $this->assertCount(1, $this->search->search($querydata)); } public function test_reindexing_files() { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); $record = $this->generator->create_record(); $doc = $area->get_document($record); // Now we are going to make some files. $fs = get_file_storage(); $syscontext = \context_system::instance(); $files = array(); $filerecord = new \stdClass(); // We make enough so that we pass the 500 files threashold. That is the boundary when getting files. $boundary = 500; $top = (int)($boundary * 1.1); for ($i = 0; $i < $top; $i++) { $filerecord->filename = 'searchfile'.$i; $filerecord->content = 'Some FileContents'.$i; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $files[] = $file; } // Add the doc with lots of files, then commit. $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); // Indexes we are going to check. 0 means we will delete, 1 means we will keep. $checkfiles = array( 0 => 0, // Check the begining of the set. 1 => 1, 2 => 0, ($top - 3) => 0, // Check the end of the set. ($top - 2) => 1, ($top - 1) => 0, ($boundary - 2) => 0, // Check at the boundary between fetch groups. ($boundary - 1) => 0, $boundary => 0, ($boundary + 1) => 0, ((int)($boundary * 0.5)) => 1, // Make sure we keep some middle ones. ((int)($boundary * 1.05)) => 1 ); $querydata = new stdClass(); // First, check that all the files are currently there. foreach ($checkfiles as $key => $unused) { $querydata->q = 'FileContents'.$key; $this->assertCount(1, $this->search->search($querydata)); $querydata->q = 'searchfile'.$key; $this->assertCount(1, $this->search->search($querydata)); } // Remove the files we want removed from the files array. foreach ($checkfiles as $key => $keep) { if (!$keep) { unset($files[$key]); } } // And make us a new file to add. $filerecord->filename = 'searchfileNew'; $filerecord->content = 'Some FileContentsNew'; $files[] = $this->generator->create_file($filerecord); $checkfiles['New'] = 1; $doc = $area->get_document($record); foreach($files as $file) { $doc->add_stored_file($file); } // Reindex the document with the changed files. $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); // Go through our check array, and see if the file is there or not. foreach ($checkfiles as $key => $keep) { $querydata->q = 'FileContents'.$key; $this->assertCount($keep, $this->search->search($querydata)); $querydata->q = 'searchfile'.$key; $this->assertCount($keep, $this->search->search($querydata)); } // Now check that we get one result when we search from something in all of them. $querydata->q = 'Some'; $this->assertCount(1, $this->search->search($querydata)); } /** * Test indexing a file we don't consider indexable. */ public function test_index_filtered_file() { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); // Get a single record to make a doc from. $record = $this->generator->create_record(); $doc = $area->get_document($record); // Now we are going to make some files. $fs = get_file_storage(); $syscontext = \context_system::instance(); // We need to make a file greater than 1kB in size, which is the lowest filter size. $filerecord = new \stdClass(); $filerecord->filename = 'largefile'; $filerecord->content = 'Some LargeFindContent to find.'; for ($i = 0; $i < 200; $i++) { $filerecord->content .= ' The quick brown fox jumps over the lazy dog.'; } $this->assertGreaterThan(1024, strlen($filerecord->content)); $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $filerecord->filename = 'smallfile'; $filerecord->content = 'Some SmallFindContent to find.'; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); $querydata = new stdClass(); // We shouldn't be able to find the large file contents. $querydata->q = 'LargeFindContent'; $this->assertCount(0, $this->search->search($querydata)); // But we should be able to find the filename. $querydata->q = 'largefile'; $this->assertCount(1, $this->search->search($querydata)); // We should be able to find the small file contents. $querydata->q = 'SmallFindContent'; $this->assertCount(1, $this->search->search($querydata)); // And we should be able to find the filename. $querydata->q = 'smallfile'; $this->assertCount(1, $this->search->search($querydata)); } public function test_delete_by_id() { // First get files in the index. $file = $this->generator->create_file(); $record = new \stdClass(); $record->attachfileids = array($file->get_id()); $this->generator->create_record($record); $this->generator->create_record($record); $this->search->index(); $querydata = new stdClass(); // Then search to make sure they are there. $querydata->q = '"File contents"'; $results = $this->search->search($querydata); $this->assertCount(2, $results); $first = reset($results); $deleteid = $first->get('id'); $this->engine->delete_by_id($deleteid); // Check that we don't get a result for it anymore. $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $this->assertNotEquals($deleteid, $result->get('id')); } /** * Test that expected results are returned, even with low check_access success rate. * * @dataProvider file_indexing_provider */ public function test_solr_filling($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); // We are going to create a bunch of records that user 1 can see with 2 keywords. // Then we are going to create a bunch for user 2 with only 1 of the keywords. // If user 2 searches for both keywords, solr will return all of the user 1 results, then the user 2 results. // This is because the user 1 results will match 2 keywords, while the others will match only 1. $record = new \stdClass(); // First create a bunch of records for user 1 to see. $record->denyuserids = array($user2->id); $record->content = 'Something1 Something2'; $maxresults = (int)(\core_search\manager::MAX_RESULTS * .75); for ($i = 0; $i < $maxresults; $i++) { $this->generator->create_record($record); } // Then create a bunch of records for user 2 to see. $record->denyuserids = array($user1->id); $record->content = 'Something1'; for ($i = 0; $i < $maxresults; $i++) { $this->generator->create_record($record); } $this->search->index(); // Check that user 1 sees all their results. $this->setUser($user1); $querydata = new stdClass(); $querydata->q = 'Something1 Something2'; $results = $this->search->search($querydata); $this->assertCount($maxresults, $results); // Check that user 2 will see theirs, even though they may be crouded out. $this->setUser($user2); $results = $this->search->search($querydata); $this->assertCount($maxresults, $results); } /** * Create 40 docs, that will be return from Solr in 10 hidden, 10 visible, 10 hidden, 10 visible if you query for: * Something1 Something2 Something3 Something4, with the specified user set. */ protected function setup_user_hidden_docs($user) { // These results will come first, and will not be visible by the user. $record = new \stdClass(); $record->denyuserids = array($user->id); $record->content = 'Something1 Something2 Something3 Something4'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come second, and will be visible by the user. unset($record->denyuserids); $record->content = 'Something1 Something2 Something3'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come third, and will not be visible by the user. $record->denyuserids = array($user->id); $record->content = 'Something1 Something2'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come fourth, and will be visible by the user. unset($record->denyuserids); $record->content = 'Something1 '; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } } /** * Test that counts are what we expect. * * @dataProvider file_indexing_provider */ public function test_get_query_total_count($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $user = self::getDataGenerator()->create_user(); $this->setup_user_hidden_docs($user); $this->search->index(); $this->setUser($user); $querydata = new stdClass(); $querydata->q = 'Something1 Something2 Something3 Something4'; // In this first set, it should have determined the first 10 of 40 are bad, so there could be up to 30 left. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 5); $this->assertEquals(30, $this->engine->get_query_total_count()); $this->assertCount(5, $results); // To get to 15, it has to process the first 10 that are bad, 10 that are good, 10 that are bad, then 5 that are good. // So we now know 20 are bad out of 40. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 15); $this->assertEquals(20, $this->engine->get_query_total_count()); $this->assertCount(15, $results); // Try to get more then all, make sure we still see 20 count and 20 returned. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 30); $this->assertEquals(20, $this->engine->get_query_total_count()); $this->assertCount(20, $results); } /** * Test that paged results are what we expect. * * @dataProvider file_indexing_provider */ public function test_manager_paged_search($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $user = self::getDataGenerator()->create_user(); $this->setup_user_hidden_docs($user); $this->search->index(); // Check that user 1 sees all their results. $this->setUser($user); $querydata = new stdClass(); $querydata->q = 'Something1 Something2 Something3 Something4'; // On this first page, it should have determined the first 10 of 40 are bad, so there could be up to 30 left. $results = $this->search->paged_search($querydata, 0); $this->assertEquals(30, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(0, $results->actualpage); // On the second page, it should have found the next 10 bad ones, so we no know there are only 20 total. $results = $this->search->paged_search($querydata, 1); $this->assertEquals(20, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(1, $results->actualpage); // Try to get an additional page - we should get back page 1 results, since that is the last page with valid results. $results = $this->search->paged_search($querydata, 2); $this->assertEquals(20, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(1, $results->actualpage); } /** * Tests searching for results restricted to context id. */ public function test_context_restriction() { // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create 2 courses and some forums. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1', 'summary' => 'xyzzy']); $contextc1 = \context_course::instance($course1->id); $course1forum1 = $generator->create_module('forum', ['course' => $course1, 'name' => 'C1F1', 'intro' => 'xyzzy']); $contextc1f1 = \context_module::instance($course1forum1->cmid); $course1forum2 = $generator->create_module('forum', ['course' => $course1, 'name' => 'C1F2', 'intro' => 'xyzzy']); $contextc1f2 = \context_module::instance($course1forum2->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2', 'summary' => 'xyzzy']); $contextc2 = \context_course::instance($course1->id); $course2forum = $generator->create_module('forum', ['course' => $course2, 'name' => 'C2F', 'intro' => 'xyzzy']); $contextc2f = \context_module::instance($course2forum->cmid); // Index the courses and forums. $this->search->index(); // Search as admin user should find everything. $querydata = new stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assert_result_titles( ['Course 1', 'Course 2', 'C1F1', 'C1F2', 'C2F'], $results); // Admin user manually restricts results by context id to include one course and one forum. $querydata->contextids = [$contextc2f->id, $contextc1->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1', 'C2F'], $results); // Student enrolled in only one course, same restriction, only has the available results. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course2->id, 'student'); $this->setUser($student2); $results = $this->search->search($querydata); $this->assert_result_titles(['C2F'], $results); // Student enrolled in both courses, same restriction, same results as admin. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $this->setUser($student1); $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1', 'C2F'], $results); // Restrict both course and context. $querydata->courseids = [$course2->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['C2F'], $results); unset($querydata->courseids); // Restrict both area and context. $querydata->areaids = ['core_course-course']; $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1'], $results); // Restrict area and context, incompatibly - this has no results (and doesn't do a query). $querydata->contextids = [$contextc2f->id]; $results = $this->search->search($querydata); $this->assert_result_titles([], $results); } /** * Tests searching for results in groups, either by specified group ids or based on user * access permissions. */ public function test_groups() { global $USER; // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create 2 courses and a selection of forums with different group mode. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $forum1nogroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => NOGROUPS]); $forum1separategroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => SEPARATEGROUPS]); $forum1visiblegroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => VISIBLEGROUPS]); $course2 = $generator->create_course(['fullname' => 'Course 2']); $forum2separategroups = $generator->create_module('forum', ['course' => $course2, 'groupmode' => SEPARATEGROUPS]); // Create two groups on each course. $group1a = $generator->create_group(['courseid' => $course1->id]); $group1b = $generator->create_group(['courseid' => $course1->id]); $group2a = $generator->create_group(['courseid' => $course2->id]); $group2b = $generator->create_group(['courseid' => $course2->id]); // Create search records in each activity and (where relevant) in each group. $forumgenerator = $generator->get_plugin_generator('mod_forum'); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1nogroups->id, 'name' => 'F1NG', 'message' => 'xyzzy']); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1separategroups->id, 'name' => 'F1SG-A', 'message' => 'xyzzy', 'groupid' => $group1a->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1separategroups->id, 'name' => 'F1SG-B', 'message' => 'xyzzy', 'groupid' => $group1b->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-A', 'message' => 'xyzzy', 'groupid' => $group1a->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-B', 'message' => 'xyzzy', 'groupid' => $group1b->id]); $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id, 'forum' => $forum2separategroups->id, 'name' => 'F2SG-A', 'message' => 'xyzzy', 'groupid' => $group2a->id]); $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id, 'forum' => $forum2separategroups->id, 'name' => 'F2SG-B', 'message' => 'xyzzy', 'groupid' => $group2b->id]); $this->search->index(); // Search as admin user should find everything. $querydata = new stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assert_result_titles( ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results); // Admin user manually restricts results by groups. $querydata->groupids = [$group1b->id, $group2a->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1SG-B', 'F1VG-B', 'F2SG-A'], $results); // Student enrolled in both courses but no groups. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $this->setUser($student1); unset($querydata->groupids); $results = $this->search->search($querydata); $this->assert_result_titles(['F1NG', 'F1VG-A', 'F1VG-B'], $results); // Student enrolled in both courses and group A in both cases. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course1->id, 'student'); $generator->enrol_user($student2->id, $course2->id, 'student'); groups_add_member($group1a, $student2); groups_add_member($group2a, $student2); $this->setUser($student2); $results = $this->search->search($querydata); $this->assert_result_titles(['F1NG', 'F1SG-A', 'F1VG-A', 'F1VG-B', 'F2SG-A'], $results); // Manually restrict results to group B in course 1. $querydata->groupids = [$group1b->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1VG-B'], $results); // Manually restrict results to group A in course 1. $querydata->groupids = [$group1a->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1SG-A', 'F1VG-A'], $results); // Manager enrolled in both courses (has access all groups). $manager = $generator->create_user(); $generator->enrol_user($manager->id, $course1->id, 'manager'); $generator->enrol_user($manager->id, $course2->id, 'manager'); $this->setUser($manager); unset($querydata->groupids); $results = $this->search->search($querydata); $this->assert_result_titles( ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results); } /** * Tests searching for results restricted to specific user id(s). */ public function test_user_restriction() { // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create a course, a forum, and a glossary. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); $glossary = $generator->create_module('glossary', ['course' => $course->id]); // Create 3 user accounts, all enrolled as students on the course. $user1 = $generator->create_user(); $user2 = $generator->create_user(); $user3 = $generator->create_user(); $generator->enrol_user($user1->id, $course->id, 'student'); $generator->enrol_user($user2->id, $course->id, 'student'); $generator->enrol_user($user3->id, $course->id, 'student'); // All users create a forum discussion. $forumgen = $generator->get_plugin_generator('mod_forum'); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user1->id, 'name' => 'Post1', 'message' => 'plugh']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user2->id, 'name' => 'Post2', 'message' => 'plugh']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user3->id, 'name' => 'Post3', 'message' => 'plugh']); // Two of the users create entries in the glossary. $glossarygen = $generator->get_plugin_generator('mod_glossary'); $glossarygen->create_content($glossary, ['concept' => 'Entry1', 'definition' => 'plugh', 'userid' => $user1->id]); $glossarygen->create_content($glossary, ['concept' => 'Entry3', 'definition' => 'plugh', 'userid' => $user3->id]); // Index the data. $this->search->index(); // Search without user restriction should find everything. $querydata = new stdClass(); $querydata->q = 'plugh'; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1', 'Entry3', 'Post1', 'Post2', 'Post3'], $results); // Restriction to user 3 only. $querydata->userids = [$user3->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry3', 'Post3'], $results); // Restriction to users 1 and 2. $querydata->userids = [$user1->id, $user2->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1', 'Post1', 'Post2'], $results); // Restriction to users 1 and 2 combined with context restriction. $querydata->contextids = [context_module::instance($glossary->cmid)->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1'], $results); // Restriction to users 1 and 2 combined with area restriction. unset($querydata->contextids); $querydata->areaids = [\core_search\manager::generate_areaid('mod_forum', 'post')]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Post1', 'Post2'], $results); } /** * Asserts that the returned documents have the expected titles (regardless of order). * * @param string[] $expected List of expected document titles * @param \core_search\document[] $results List of returned documents */ protected function assert_result_titles(array $expected, array $results) { $titles = []; foreach ($results as $result) { $titles[] = $result->get('title'); } sort($titles); sort($expected); $this->assertEquals($expected, $titles); } /** * Tests the get_supported_orders function for contexts where we can only use relevance * (system, category). */ public function test_get_supported_orders_relevance_only() { global $DB; // System or category context: relevance only. $orders = $this->engine->get_supported_orders(\context_system::instance()); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); $categoryid = $DB->get_field_sql('SELECT MIN(id) FROM {course_categories}'); $orders = $this->engine->get_supported_orders(\context_coursecat::instance($categoryid)); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); } /** * Tests the get_supported_orders function for contexts where we support location as well * (course, activity, block). */ public function test_get_supported_orders_relevance_and_location() { global $DB; // Test with course context. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'Frogs']); $coursecontext = \context_course::instance($course->id); $orders = $this->engine->get_supported_orders($coursecontext); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertContains('Course: Frogs', $orders['location']); // Test with activity context. $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'Toads']); $orders = $this->engine->get_supported_orders(\context_module::instance($page->cmid)); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertContains('Page: Toads', $orders['location']); // Test with block context. $instance = (object)['blockname' => 'html', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => '']; $blockid = $DB->insert_record('block_instances', $instance); $blockcontext = \context_block::instance($blockid); $orders = $this->engine->get_supported_orders($blockcontext); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertContains('Block: HTML', $orders['location']); } /** * Tests ordering by relevance vs location. */ public function test_ordering() { // Create 2 courses and 2 activities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); $course1page = $generator->create_module('page', ['course' => $course1]); $course1pagecontext = \context_module::instance($course1page->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2']); $course2context = \context_course::instance($course2->id); $course2page = $generator->create_module('page', ['course' => $course2]); $course2pagecontext = \context_module::instance($course2page->cmid); // Create one search record in each activity and course. $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy'); $this->create_search_record($course1->id, $course1pagecontext->id, 'C1P', 'Xyzzy'); $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy'); $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh'); $this->search->index(); // Default search works by relevance so the one with both words should be top. $querydata = new stdClass(); $querydata->q = 'xyzzy plugh'; $results = $this->search->search($querydata); $this->assertCount(4, $results); $this->assertEquals('C2P', $results[0]->get('title')); // Same if you explicitly specify relevance. $querydata->order = 'relevance'; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); // If you specify order by location and you are in C2 or C2P then results are the same. $querydata->order = 'location'; $querydata->context = $course2context; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); $querydata->context = $course2pagecontext; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); // But if you are in C1P then you get different results (C1P first). $querydata->context = $course1pagecontext; $results = $this->search->search($querydata); $this->assertEquals('C1P', $results[0]->get('title')); } /** * Tests with bogus content (that can be entered into Moodle) to see if it crashes. */ public function test_bogus_content() { $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); // It is possible to enter into a Moodle database content containing these characters, // which are Unicode non-characters / byte order marks. If sent to Solr, these cause // failures. $boguscontent = html_entity_decode('￾') . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('￿') . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); // Unicode Standard Version 9.0 - Core Specification, section 23.7, lists 66 non-characters // in total. Here are some of them - these work OK for me but it may depend on platform. $boguscontent = html_entity_decode('﷐') . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('﷯') . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('🿾') . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('􏿿') . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); // Do the indexing (this will check it doesn't throw warnings). $this->search->index(); // Confirm that all 6 documents are found in search. $querydata = new stdClass(); $querydata->q = 'frog'; $results = $this->search->search($querydata); $this->assertCount(6, $results); } /** * Adds a record to the mock search area, so that the search engine can find it later. * * @param int $courseid Course id * @param int $contextid Context id * @param string $title Title for search index * @param string $content Content for search index */ protected function create_search_record($courseid, $contextid, $title, $content) { $record = new \stdClass(); $record->content = $content; $record->title = $title; $record->courseid = $courseid; $record->contextid = $contextid; $this->generator->create_record($record); } }