Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Solr earch engine base unit tests.
  19   *
  20   * Required params:
  21   * - define('TEST_SEARCH_SOLR_HOSTNAME', '127.0.0.1');
  22   * - define('TEST_SEARCH_SOLR_PORT', '8983');
  23   * - define('TEST_SEARCH_SOLR_INDEXNAME', 'unittest');
  24   *
  25   * Optional params:
  26   * - define('TEST_SEARCH_SOLR_USERNAME', '');
  27   * - define('TEST_SEARCH_SOLR_PASSWORD', '');
  28   * - define('TEST_SEARCH_SOLR_SSLCERT', '');
  29   * - define('TEST_SEARCH_SOLR_SSLKEY', '');
  30   * - define('TEST_SEARCH_SOLR_KEYPASSWORD', '');
  31   * - define('TEST_SEARCH_SOLR_CAINFOCERT', '');
  32   *
  33   * @package     search_solr
  34   * @category    phpunit
  35   * @copyright   2015 David Monllao {@link http://www.davidmonllao.com}
  36   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  
  39  defined('MOODLE_INTERNAL') || die();
  40  
  41  global $CFG;
  42  require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
  43  require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php');
  44  require_once($CFG->dirroot . '/search/engine/solr/tests/fixtures/testable_engine.php');
  45  
  46  /**
  47   * Solr search engine base unit tests.
  48   *
  49   * @package     search_solr
  50   * @category    phpunit
  51   * @copyright   2015 David Monllao {@link http://www.davidmonllao.com}
  52   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  53   */
  54  class search_solr_engine_testcase extends advanced_testcase {
  55  
  56      /**
  57       * @var \core_search\manager
  58       */
  59      protected $search = null;
  60  
  61      /**
  62       * @var Instace of core_search_generator.
  63       */
  64      protected $generator = null;
  65  
  66      /**
  67       * @var Instace of testable_engine.
  68       */
  69      protected $engine = null;
  70  
  71      public function setUp() {
  72          $this->resetAfterTest();
  73          set_config('enableglobalsearch', true);
  74          set_config('searchengine', 'solr');
  75  
  76          if (!function_exists('solr_get_version')) {
  77              $this->markTestSkipped('Solr extension is not loaded.');
  78          }
  79  
  80          if (!defined('TEST_SEARCH_SOLR_HOSTNAME') || !defined('TEST_SEARCH_SOLR_INDEXNAME') ||
  81                  !defined('TEST_SEARCH_SOLR_PORT')) {
  82              $this->markTestSkipped('Solr extension test server not set.');
  83          }
  84  
  85          set_config('server_hostname', TEST_SEARCH_SOLR_HOSTNAME, 'search_solr');
  86          set_config('server_port', TEST_SEARCH_SOLR_PORT, 'search_solr');
  87          set_config('indexname', TEST_SEARCH_SOLR_INDEXNAME, 'search_solr');
  88  
  89          if (defined('TEST_SEARCH_SOLR_USERNAME')) {
  90              set_config('server_username', TEST_SEARCH_SOLR_USERNAME, 'search_solr');
  91          }
  92  
  93          if (defined('TEST_SEARCH_SOLR_PASSWORD')) {
  94              set_config('server_password', TEST_SEARCH_SOLR_PASSWORD, 'search_solr');
  95          }
  96  
  97          if (defined('TEST_SEARCH_SOLR_SSLCERT')) {
  98              set_config('secure', true, 'search_solr');
  99              set_config('ssl_cert', TEST_SEARCH_SOLR_SSLCERT, 'search_solr');
 100          }
 101  
 102          if (defined('TEST_SEARCH_SOLR_SSLKEY')) {
 103              set_config('ssl_key', TEST_SEARCH_SOLR_SSLKEY, 'search_solr');
 104          }
 105  
 106          if (defined('TEST_SEARCH_SOLR_KEYPASSWORD')) {
 107              set_config('ssl_keypassword', TEST_SEARCH_SOLR_KEYPASSWORD, 'search_solr');
 108          }
 109  
 110          if (defined('TEST_SEARCH_SOLR_CAINFOCERT')) {
 111              set_config('ssl_cainfo', TEST_SEARCH_SOLR_CAINFOCERT, 'search_solr');
 112          }
 113  
 114          set_config('fileindexing', 1, 'search_solr');
 115  
 116          // We are only test indexing small string files, so setting this as low as we can.
 117          set_config('maxindexfilekb', 1, 'search_solr');
 118  
 119          $this->generator = self::getDataGenerator()->get_plugin_generator('core_search');
 120          $this->generator->setup();
 121  
 122          // Inject search solr engine into the testable core search as we need to add the mock
 123          // search component to it.
 124          $this->engine = new \search_solr\testable_engine();
 125          $this->search = testable_core_search::instance($this->engine);
 126          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 127          $this->search->add_search_area($areaid, new core_mocksearch\search\mock_search_area());
 128  
 129          $this->setAdminUser();
 130  
 131          // Cleanup before doing anything on it as the index it is out of this test control.
 132          $this->search->delete_index();
 133  
 134          // Add moodle fields if they don't exist.
 135          $schema = new \search_solr\schema();
 136          $schema->setup(false);
 137      }
 138  
 139      public function tearDown() {
 140          // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup.
 141          if ($this->generator) {
 142              // Moodle DML freaks out if we don't teardown the temp table after each run.
 143              $this->generator->teardown();
 144              $this->generator = null;
 145          }
 146      }
 147  
 148      /**
 149       * Simple data provider to allow tests to be run with file indexing on and off.
 150       */
 151      public function file_indexing_provider() {
 152          return array(
 153              'file-indexing-on' => array(1),
 154              'file-indexing-off' => array(0)
 155          );
 156      }
 157  
 158      public function test_connection() {
 159          $this->assertTrue($this->engine->is_server_ready());
 160      }
 161  
 162      /**
 163       * @dataProvider file_indexing_provider
 164       */
 165      public function test_index($fileindexing) {
 166          global $DB;
 167  
 168          $this->engine->test_set_config('fileindexing', $fileindexing);
 169  
 170          $record = new \stdClass();
 171          $record->timemodified = time() - 1;
 172          $this->generator->create_record($record);
 173  
 174          // Data gets into the search engine.
 175          $this->assertTrue($this->search->index());
 176  
 177          // Not anymore as everything was already added.
 178          sleep(1);
 179          $this->assertFalse($this->search->index());
 180  
 181          $this->generator->create_record();
 182  
 183          // Indexing again once there is new data.
 184          $this->assertTrue($this->search->index());
 185      }
 186  
 187      /**
 188       * Better keep this not very strict about which or how many results are returned as may depend on solr engine config.
 189       *
 190       * @dataProvider file_indexing_provider
 191       *
 192       * @return void
 193       */
 194      public function test_search($fileindexing) {
 195          global $USER, $DB;
 196  
 197          $this->engine->test_set_config('fileindexing', $fileindexing);
 198  
 199          $this->generator->create_record();
 200          $record = new \stdClass();
 201          $record->title = "Special title";
 202          $this->generator->create_record($record);
 203  
 204          $this->search->index();
 205  
 206          $querydata = new stdClass();
 207          $querydata->q = 'message';
 208          $results = $this->search->search($querydata);
 209          $this->assertCount(2, $results);
 210  
 211          // Based on core_mocksearch\search\indexer.
 212          $this->assertEquals($USER->id, $results[0]->get('userid'));
 213          $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid'));
 214  
 215          // Do a test to make sure we aren't searching non-query fields, like areaid.
 216          $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 217          $this->assertCount(0, $this->search->search($querydata));
 218          $querydata->q = 'message';
 219  
 220          sleep(1);
 221          $beforeadding = time();
 222          sleep(1);
 223          $this->generator->create_record();
 224          $this->search->index();
 225  
 226          // Timestart.
 227          $querydata->timestart = $beforeadding;
 228          $this->assertCount(1, $this->search->search($querydata));
 229  
 230          // Timeend.
 231          unset($querydata->timestart);
 232          $querydata->timeend = $beforeadding;
 233          $this->assertCount(2, $this->search->search($querydata));
 234  
 235          // Title.
 236          unset($querydata->timeend);
 237          $querydata->title = 'Special title';
 238          $this->assertCount(1, $this->search->search($querydata));
 239  
 240          // Course IDs.
 241          unset($querydata->title);
 242          $querydata->courseids = array(SITEID + 1);
 243          $this->assertCount(0, $this->search->search($querydata));
 244  
 245          $querydata->courseids = array(SITEID);
 246          $this->assertCount(3, $this->search->search($querydata));
 247  
 248          // Now try some area-id combinations.
 249          unset($querydata->courseids);
 250          $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post');
 251          $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 252  
 253          $querydata->areaids = array($forumpostareaid);
 254          $this->assertCount(0, $this->search->search($querydata));
 255  
 256          $querydata->areaids = array($forumpostareaid, $mockareaid);
 257          $this->assertCount(3, $this->search->search($querydata));
 258  
 259          $querydata->areaids = array($mockareaid);
 260          $this->assertCount(3, $this->search->search($querydata));
 261  
 262          $querydata->areaids = array();
 263          $this->assertCount(3, $this->search->search($querydata));
 264  
 265          // Check that index contents get updated.
 266          $this->generator->delete_all();
 267          $this->search->index(true);
 268          unset($querydata->title);
 269          $querydata->q = '*';
 270          $this->assertCount(0, $this->search->search($querydata));
 271      }
 272  
 273      /**
 274       * @dataProvider file_indexing_provider
 275       */
 276      public function test_delete($fileindexing) {
 277          $this->engine->test_set_config('fileindexing', $fileindexing);
 278  
 279          $this->generator->create_record();
 280          $this->generator->create_record();
 281          $this->search->index();
 282  
 283          $querydata = new stdClass();
 284          $querydata->q = 'message';
 285  
 286          $this->assertCount(2, $this->search->search($querydata));
 287  
 288          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 289          $this->search->delete_index($areaid);
 290          $this->assertCount(0, $this->search->search($querydata));
 291      }
 292  
 293      /**
 294       * @dataProvider file_indexing_provider
 295       */
 296      public function test_alloweduserid($fileindexing) {
 297          $this->engine->test_set_config('fileindexing', $fileindexing);
 298  
 299          $area = new core_mocksearch\search\mock_search_area();
 300  
 301          $record = $this->generator->create_record();
 302  
 303          // Get the doc and insert the default doc.
 304          $doc = $area->get_document($record);
 305          $this->engine->add_document($doc);
 306  
 307          $users = array();
 308          $users[] = $this->getDataGenerator()->create_user();
 309          $users[] = $this->getDataGenerator()->create_user();
 310          $users[] = $this->getDataGenerator()->create_user();
 311  
 312          // Add a record that only user 100 can see.
 313          $originalid = $doc->get('id');
 314  
 315          // Now add a custom doc for each user.
 316          foreach ($users as $user) {
 317              $doc = $area->get_document($record);
 318              $doc->set('id', $originalid.'-'.$user->id);
 319              $doc->set('owneruserid', $user->id);
 320              $this->engine->add_document($doc);
 321          }
 322  
 323          $this->engine->area_index_complete($area->get_area_id());
 324  
 325          $querydata = new stdClass();
 326          $querydata->q = 'message';
 327          $querydata->title = $doc->get('title');
 328  
 329          // We are going to go through each user and see if they get the original and the owned doc.
 330          foreach ($users as $user) {
 331              $this->setUser($user);
 332  
 333              $results = $this->search->search($querydata);
 334              $this->assertCount(2, $results);
 335  
 336              $owned = 0;
 337              $notowned = 0;
 338  
 339              // We don't know what order we will get the results in, so we are doing this.
 340              foreach ($results as $result) {
 341                  $owneruserid = $result->get('owneruserid');
 342                  if (empty($owneruserid)) {
 343                      $notowned++;
 344                      $this->assertEquals(0, $owneruserid);
 345                      $this->assertEquals($originalid, $result->get('id'));
 346                  } else {
 347                      $owned++;
 348                      $this->assertEquals($user->id, $owneruserid);
 349                      $this->assertEquals($originalid.'-'.$user->id, $result->get('id'));
 350                  }
 351              }
 352  
 353              $this->assertEquals(1, $owned);
 354              $this->assertEquals(1, $notowned);
 355          }
 356  
 357          // Now test a user with no owned results.
 358          $otheruser = $this->getDataGenerator()->create_user();
 359          $this->setUser($otheruser);
 360  
 361          $results = $this->search->search($querydata);
 362          $this->assertCount(1, $results);
 363  
 364          $this->assertEquals(0, $results[0]->get('owneruserid'));
 365          $this->assertEquals($originalid, $results[0]->get('id'));
 366      }
 367  
 368      /**
 369       * @dataProvider file_indexing_provider
 370       */
 371      public function test_highlight($fileindexing) {
 372          global $PAGE;
 373  
 374          $this->engine->test_set_config('fileindexing', $fileindexing);
 375  
 376          $this->generator->create_record();
 377          $this->search->index();
 378  
 379          $querydata = new stdClass();
 380          $querydata->q = 'message';
 381  
 382          $results = $this->search->search($querydata);
 383          $this->assertCount(1, $results);
 384  
 385          $result = reset($results);
 386  
 387          $regex = '|'.\search_solr\engine::HIGHLIGHT_START.'message'.\search_solr\engine::HIGHLIGHT_END.'|';
 388          $this->assertRegExp($regex, $result->get('content'));
 389  
 390          $searchrenderer = $PAGE->get_renderer('core_search');
 391          $exported = $result->export_for_template($searchrenderer);
 392  
 393          $regex = '|<span class="highlight">message</span>|';
 394          $this->assertRegExp($regex, $exported['content']);
 395      }
 396  
 397      public function test_export_file_for_engine() {
 398          // Get area to work with.
 399          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 400          $area = \core_search\manager::get_search_area($areaid);
 401  
 402          $record = $this->generator->create_record();
 403  
 404          $doc = $area->get_document($record);
 405          $filerecord = new stdClass();
 406          $filerecord->timemodified  = 978310800;
 407          $file = $this->generator->create_file($filerecord);
 408          $doc->add_stored_file($file);
 409  
 410          $filearray = $doc->export_file_for_engine($file);
 411  
 412          $this->assertEquals(\core_search\manager::TYPE_FILE, $filearray['type']);
 413          $this->assertEquals($file->get_id(), $filearray['solr_fileid']);
 414          $this->assertEquals($file->get_contenthash(), $filearray['solr_filecontenthash']);
 415          $this->assertEquals(\search_solr\document::INDEXED_FILE_TRUE, $filearray['solr_fileindexstatus']);
 416          $this->assertEquals($file->get_filename(), $filearray['title']);
 417          $this->assertEquals(978310800, \search_solr\document::import_time_from_engine($filearray['modified']));
 418      }
 419  
 420      public function test_index_file() {
 421          // Very simple test.
 422          $file = $this->generator->create_file();
 423  
 424          $record = new \stdClass();
 425          $record->attachfileids = array($file->get_id());
 426          $this->generator->create_record($record);
 427  
 428          $this->search->index();
 429          $querydata = new stdClass();
 430          $querydata->q = '"File contents"';
 431  
 432          $this->assertCount(1, $this->search->search($querydata));
 433      }
 434  
 435      public function test_reindexing_files() {
 436          // Get area to work with.
 437          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 438          $area = \core_search\manager::get_search_area($areaid);
 439  
 440          $record = $this->generator->create_record();
 441  
 442          $doc = $area->get_document($record);
 443  
 444          // Now we are going to make some files.
 445          $fs = get_file_storage();
 446          $syscontext = \context_system::instance();
 447  
 448          $files = array();
 449  
 450          $filerecord = new \stdClass();
 451          // We make enough so that we pass the 500 files threashold. That is the boundary when getting files.
 452          $boundary = 500;
 453          $top = (int)($boundary * 1.1);
 454          for ($i = 0; $i < $top; $i++) {
 455              $filerecord->filename  = 'searchfile'.$i;
 456              $filerecord->content = 'Some FileContents'.$i;
 457              $file = $this->generator->create_file($filerecord);
 458              $doc->add_stored_file($file);
 459              $files[] = $file;
 460          }
 461  
 462          // Add the doc with lots of files, then commit.
 463          $this->engine->add_document($doc, true);
 464          $this->engine->area_index_complete($area->get_area_id());
 465  
 466          // Indexes we are going to check. 0 means we will delete, 1 means we will keep.
 467          $checkfiles = array(
 468              0 => 0,                        // Check the begining of the set.
 469              1 => 1,
 470              2 => 0,
 471              ($top - 3) => 0,               // Check the end of the set.
 472              ($top - 2) => 1,
 473              ($top - 1) => 0,
 474              ($boundary - 2) => 0,          // Check at the boundary between fetch groups.
 475              ($boundary - 1) => 0,
 476              $boundary => 0,
 477              ($boundary + 1) => 0,
 478              ((int)($boundary * 0.5)) => 1, // Make sure we keep some middle ones.
 479              ((int)($boundary * 1.05)) => 1
 480          );
 481  
 482          $querydata = new stdClass();
 483  
 484          // First, check that all the files are currently there.
 485          foreach ($checkfiles as $key => $unused) {
 486              $querydata->q = 'FileContents'.$key;
 487              $this->assertCount(1, $this->search->search($querydata));
 488              $querydata->q = 'searchfile'.$key;
 489              $this->assertCount(1, $this->search->search($querydata));
 490          }
 491  
 492          // Remove the files we want removed from the files array.
 493          foreach ($checkfiles as $key => $keep) {
 494              if (!$keep) {
 495                  unset($files[$key]);
 496              }
 497          }
 498  
 499          // And make us a new file to add.
 500          $filerecord->filename  = 'searchfileNew';
 501          $filerecord->content  = 'Some FileContentsNew';
 502          $files[] = $this->generator->create_file($filerecord);
 503          $checkfiles['New'] = 1;
 504  
 505          $doc = $area->get_document($record);
 506          foreach($files as $file) {
 507              $doc->add_stored_file($file);
 508          }
 509  
 510          // Reindex the document with the changed files.
 511          $this->engine->add_document($doc, true);
 512          $this->engine->area_index_complete($area->get_area_id());
 513  
 514          // Go through our check array, and see if the file is there or not.
 515          foreach ($checkfiles as $key => $keep) {
 516              $querydata->q = 'FileContents'.$key;
 517              $this->assertCount($keep, $this->search->search($querydata));
 518              $querydata->q = 'searchfile'.$key;
 519              $this->assertCount($keep, $this->search->search($querydata));
 520          }
 521  
 522          // Now check that we get one result when we search from something in all of them.
 523          $querydata->q = 'Some';
 524          $this->assertCount(1, $this->search->search($querydata));
 525      }
 526  
 527      /**
 528       * Test indexing a file we don't consider indexable.
 529       */
 530      public function test_index_filtered_file() {
 531          // Get area to work with.
 532          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 533          $area = \core_search\manager::get_search_area($areaid);
 534  
 535          // Get a single record to make a doc from.
 536          $record = $this->generator->create_record();
 537  
 538          $doc = $area->get_document($record);
 539  
 540          // Now we are going to make some files.
 541          $fs = get_file_storage();
 542          $syscontext = \context_system::instance();
 543  
 544          // We need to make a file greater than 1kB in size, which is the lowest filter size.
 545          $filerecord = new \stdClass();
 546          $filerecord->filename = 'largefile';
 547          $filerecord->content = 'Some LargeFindContent to find.';
 548          for ($i = 0; $i < 200; $i++) {
 549              $filerecord->content .= ' The quick brown fox jumps over the lazy dog.';
 550          }
 551  
 552          $this->assertGreaterThan(1024, strlen($filerecord->content));
 553  
 554          $file = $this->generator->create_file($filerecord);
 555          $doc->add_stored_file($file);
 556  
 557          $filerecord->filename = 'smallfile';
 558          $filerecord->content = 'Some SmallFindContent to find.';
 559          $file = $this->generator->create_file($filerecord);
 560          $doc->add_stored_file($file);
 561  
 562          $this->engine->add_document($doc, true);
 563          $this->engine->area_index_complete($area->get_area_id());
 564  
 565          $querydata = new stdClass();
 566          // We shouldn't be able to find the large file contents.
 567          $querydata->q = 'LargeFindContent';
 568          $this->assertCount(0, $this->search->search($querydata));
 569  
 570          // But we should be able to find the filename.
 571          $querydata->q = 'largefile';
 572          $this->assertCount(1, $this->search->search($querydata));
 573  
 574          // We should be able to find the small file contents.
 575          $querydata->q = 'SmallFindContent';
 576          $this->assertCount(1, $this->search->search($querydata));
 577  
 578          // And we should be able to find the filename.
 579          $querydata->q = 'smallfile';
 580          $this->assertCount(1, $this->search->search($querydata));
 581      }
 582  
 583      public function test_delete_by_id() {
 584          // First get files in the index.
 585          $file = $this->generator->create_file();
 586          $record = new \stdClass();
 587          $record->attachfileids = array($file->get_id());
 588          $this->generator->create_record($record);
 589          $this->generator->create_record($record);
 590          $this->search->index();
 591  
 592          $querydata = new stdClass();
 593  
 594          // Then search to make sure they are there.
 595          $querydata->q = '"File contents"';
 596          $results = $this->search->search($querydata);
 597          $this->assertCount(2, $results);
 598  
 599          $first = reset($results);
 600          $deleteid = $first->get('id');
 601  
 602          $this->engine->delete_by_id($deleteid);
 603  
 604          // Check that we don't get a result for it anymore.
 605          $results = $this->search->search($querydata);
 606          $this->assertCount(1, $results);
 607          $result = reset($results);
 608          $this->assertNotEquals($deleteid, $result->get('id'));
 609      }
 610  
 611      /**
 612       * Test that expected results are returned, even with low check_access success rate.
 613       *
 614       * @dataProvider file_indexing_provider
 615       */
 616      public function test_solr_filling($fileindexing) {
 617          $this->engine->test_set_config('fileindexing', $fileindexing);
 618  
 619          $user1 = self::getDataGenerator()->create_user();
 620          $user2 = self::getDataGenerator()->create_user();
 621  
 622          // We are going to create a bunch of records that user 1 can see with 2 keywords.
 623          // Then we are going to create a bunch for user 2 with only 1 of the keywords.
 624          // If user 2 searches for both keywords, solr will return all of the user 1 results, then the user 2 results.
 625          // This is because the user 1 results will match 2 keywords, while the others will match only 1.
 626  
 627          $record = new \stdClass();
 628  
 629          // First create a bunch of records for user 1 to see.
 630          $record->denyuserids = array($user2->id);
 631          $record->content = 'Something1 Something2';
 632          $maxresults = (int)(\core_search\manager::MAX_RESULTS * .75);
 633          for ($i = 0; $i < $maxresults; $i++) {
 634              $this->generator->create_record($record);
 635          }
 636  
 637          // Then create a bunch of records for user 2 to see.
 638          $record->denyuserids = array($user1->id);
 639          $record->content = 'Something1';
 640          for ($i = 0; $i < $maxresults; $i++) {
 641              $this->generator->create_record($record);
 642          }
 643  
 644          $this->search->index();
 645  
 646          // Check that user 1 sees all their results.
 647          $this->setUser($user1);
 648          $querydata = new stdClass();
 649          $querydata->q = 'Something1 Something2';
 650          $results = $this->search->search($querydata);
 651          $this->assertCount($maxresults, $results);
 652  
 653          // Check that user 2 will see theirs, even though they may be crouded out.
 654          $this->setUser($user2);
 655          $results = $this->search->search($querydata);
 656          $this->assertCount($maxresults, $results);
 657      }
 658  
 659      /**
 660       * Create 40 docs, that will be return from Solr in 10 hidden, 10 visible, 10 hidden, 10 visible if you query for:
 661       * Something1 Something2 Something3 Something4, with the specified user set.
 662       */
 663      protected function setup_user_hidden_docs($user) {
 664          // These results will come first, and will not be visible by the user.
 665          $record = new \stdClass();
 666          $record->denyuserids = array($user->id);
 667          $record->content = 'Something1 Something2 Something3 Something4';
 668          for ($i = 0; $i < 10; $i++) {
 669              $this->generator->create_record($record);
 670          }
 671  
 672          // These results will come second, and will  be visible by the user.
 673          unset($record->denyuserids);
 674          $record->content = 'Something1 Something2 Something3';
 675          for ($i = 0; $i < 10; $i++) {
 676              $this->generator->create_record($record);
 677          }
 678  
 679          // These results will come third, and will not be visible by the user.
 680          $record->denyuserids = array($user->id);
 681          $record->content = 'Something1 Something2';
 682          for ($i = 0; $i < 10; $i++) {
 683              $this->generator->create_record($record);
 684          }
 685  
 686          // These results will come fourth, and will be visible by the user.
 687          unset($record->denyuserids);
 688          $record->content = 'Something1 ';
 689          for ($i = 0; $i < 10; $i++) {
 690              $this->generator->create_record($record);
 691          }
 692      }
 693  
 694      /**
 695       * Test that counts are what we expect.
 696       *
 697       * @dataProvider file_indexing_provider
 698       */
 699      public function test_get_query_total_count($fileindexing) {
 700          $this->engine->test_set_config('fileindexing', $fileindexing);
 701  
 702          $user = self::getDataGenerator()->create_user();
 703          $this->setup_user_hidden_docs($user);
 704          $this->search->index();
 705  
 706          $this->setUser($user);
 707          $querydata = new stdClass();
 708          $querydata->q = 'Something1 Something2 Something3 Something4';
 709  
 710          // In this first set, it should have determined the first 10 of 40 are bad, so there could be up to 30 left.
 711          $results = $this->engine->execute_query($querydata, (object)['everything' => true], 5);
 712          $this->assertEquals(30, $this->engine->get_query_total_count());
 713          $this->assertCount(5, $results);
 714  
 715          // 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.
 716          // So we now know 20 are bad out of 40.
 717          $results = $this->engine->execute_query($querydata, (object)['everything' => true], 15);
 718          $this->assertEquals(20, $this->engine->get_query_total_count());
 719          $this->assertCount(15, $results);
 720  
 721          // Try to get more then all, make sure we still see 20 count and 20 returned.
 722          $results = $this->engine->execute_query($querydata, (object)['everything' => true], 30);
 723          $this->assertEquals(20, $this->engine->get_query_total_count());
 724          $this->assertCount(20, $results);
 725      }
 726  
 727      /**
 728       * Test that paged results are what we expect.
 729       *
 730       * @dataProvider file_indexing_provider
 731       */
 732      public function test_manager_paged_search($fileindexing) {
 733          $this->engine->test_set_config('fileindexing', $fileindexing);
 734  
 735          $user = self::getDataGenerator()->create_user();
 736          $this->setup_user_hidden_docs($user);
 737          $this->search->index();
 738  
 739          // Check that user 1 sees all their results.
 740          $this->setUser($user);
 741          $querydata = new stdClass();
 742          $querydata->q = 'Something1 Something2 Something3 Something4';
 743  
 744          // On this first page, it should have determined the first 10 of 40 are bad, so there could be up to 30 left.
 745          $results = $this->search->paged_search($querydata, 0);
 746          $this->assertEquals(30, $results->totalcount);
 747          $this->assertCount(10, $results->results);
 748          $this->assertEquals(0, $results->actualpage);
 749  
 750          // On the second page, it should have found the next 10 bad ones, so we no know there are only 20 total.
 751          $results = $this->search->paged_search($querydata, 1);
 752          $this->assertEquals(20, $results->totalcount);
 753          $this->assertCount(10, $results->results);
 754          $this->assertEquals(1, $results->actualpage);
 755  
 756          // Try to get an additional page - we should get back page 1 results, since that is the last page with valid results.
 757          $results = $this->search->paged_search($querydata, 2);
 758          $this->assertEquals(20, $results->totalcount);
 759          $this->assertCount(10, $results->results);
 760          $this->assertEquals(1, $results->actualpage);
 761      }
 762  
 763      /**
 764       * Tests searching for results restricted to context id.
 765       */
 766      public function test_context_restriction() {
 767          // Use real search areas.
 768          $this->search->clear_static();
 769          $this->search->add_core_search_areas();
 770  
 771          // Create 2 courses and some forums.
 772          $generator = $this->getDataGenerator();
 773          $course1 = $generator->create_course(['fullname' => 'Course 1', 'summary' => 'xyzzy']);
 774          $contextc1 = \context_course::instance($course1->id);
 775          $course1forum1 = $generator->create_module('forum', ['course' => $course1,
 776                  'name' => 'C1F1', 'intro' => 'xyzzy']);
 777          $contextc1f1 = \context_module::instance($course1forum1->cmid);
 778          $course1forum2 = $generator->create_module('forum', ['course' => $course1,
 779                  'name' => 'C1F2', 'intro' => 'xyzzy']);
 780          $contextc1f2 = \context_module::instance($course1forum2->cmid);
 781          $course2 = $generator->create_course(['fullname' => 'Course 2', 'summary' => 'xyzzy']);
 782          $contextc2 = \context_course::instance($course1->id);
 783          $course2forum = $generator->create_module('forum', ['course' => $course2,
 784                  'name' => 'C2F', 'intro' => 'xyzzy']);
 785          $contextc2f = \context_module::instance($course2forum->cmid);
 786  
 787          // Index the courses and forums.
 788          $this->search->index();
 789  
 790          // Search as admin user should find everything.
 791          $querydata = new stdClass();
 792          $querydata->q = 'xyzzy';
 793          $results = $this->search->search($querydata);
 794          $this->assert_result_titles(
 795                  ['Course 1', 'Course 2', 'C1F1', 'C1F2', 'C2F'], $results);
 796  
 797          // Admin user manually restricts results by context id to include one course and one forum.
 798          $querydata->contextids = [$contextc2f->id, $contextc1->id];
 799          $results = $this->search->search($querydata);
 800          $this->assert_result_titles(['Course 1', 'C2F'], $results);
 801  
 802          // Student enrolled in only one course, same restriction, only has the available results.
 803          $student2 = $generator->create_user();
 804          $generator->enrol_user($student2->id, $course2->id, 'student');
 805          $this->setUser($student2);
 806          $results = $this->search->search($querydata);
 807          $this->assert_result_titles(['C2F'], $results);
 808  
 809          // Student enrolled in both courses, same restriction, same results as admin.
 810          $student1 = $generator->create_user();
 811          $generator->enrol_user($student1->id, $course1->id, 'student');
 812          $generator->enrol_user($student1->id, $course2->id, 'student');
 813          $this->setUser($student1);
 814          $results = $this->search->search($querydata);
 815          $this->assert_result_titles(['Course 1', 'C2F'], $results);
 816  
 817          // Restrict both course and context.
 818          $querydata->courseids = [$course2->id];
 819          $results = $this->search->search($querydata);
 820          $this->assert_result_titles(['C2F'], $results);
 821          unset($querydata->courseids);
 822  
 823          // Restrict both area and context.
 824          $querydata->areaids = ['core_course-course'];
 825          $results = $this->search->search($querydata);
 826          $this->assert_result_titles(['Course 1'], $results);
 827  
 828          // Restrict area and context, incompatibly - this has no results (and doesn't do a query).
 829          $querydata->contextids = [$contextc2f->id];
 830          $results = $this->search->search($querydata);
 831          $this->assert_result_titles([], $results);
 832      }
 833  
 834      /**
 835       * Tests searching for results in groups, either by specified group ids or based on user
 836       * access permissions.
 837       */
 838      public function test_groups() {
 839          global $USER;
 840  
 841          // Use real search areas.
 842          $this->search->clear_static();
 843          $this->search->add_core_search_areas();
 844  
 845          // Create 2 courses and a selection of forums with different group mode.
 846          $generator = $this->getDataGenerator();
 847          $course1 = $generator->create_course(['fullname' => 'Course 1']);
 848          $forum1nogroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => NOGROUPS]);
 849          $forum1separategroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => SEPARATEGROUPS]);
 850          $forum1visiblegroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => VISIBLEGROUPS]);
 851          $course2 = $generator->create_course(['fullname' => 'Course 2']);
 852          $forum2separategroups = $generator->create_module('forum', ['course' => $course2, 'groupmode' => SEPARATEGROUPS]);
 853  
 854          // Create two groups on each course.
 855          $group1a = $generator->create_group(['courseid' => $course1->id]);
 856          $group1b = $generator->create_group(['courseid' => $course1->id]);
 857          $group2a = $generator->create_group(['courseid' => $course2->id]);
 858          $group2b = $generator->create_group(['courseid' => $course2->id]);
 859  
 860          // Create search records in each activity and (where relevant) in each group.
 861          $forumgenerator = $generator->get_plugin_generator('mod_forum');
 862          $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
 863                  'forum' => $forum1nogroups->id, 'name' => 'F1NG', 'message' => 'xyzzy']);
 864          $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
 865                  'forum' => $forum1separategroups->id, 'name' => 'F1SG-A',  'message' => 'xyzzy',
 866                  'groupid' => $group1a->id]);
 867          $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
 868                  'forum' => $forum1separategroups->id, 'name' => 'F1SG-B', 'message' => 'xyzzy',
 869                  'groupid' => $group1b->id]);
 870          $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
 871                  'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-A', 'message' => 'xyzzy',
 872                  'groupid' => $group1a->id]);
 873          $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
 874                  'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-B', 'message' => 'xyzzy',
 875                  'groupid' => $group1b->id]);
 876          $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id,
 877                  'forum' => $forum2separategroups->id, 'name' => 'F2SG-A', 'message' => 'xyzzy',
 878                  'groupid' => $group2a->id]);
 879          $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id,
 880                  'forum' => $forum2separategroups->id, 'name' => 'F2SG-B', 'message' => 'xyzzy',
 881                  'groupid' => $group2b->id]);
 882  
 883          $this->search->index();
 884  
 885          // Search as admin user should find everything.
 886          $querydata = new stdClass();
 887          $querydata->q = 'xyzzy';
 888          $results = $this->search->search($querydata);
 889          $this->assert_result_titles(
 890                  ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results);
 891  
 892          // Admin user manually restricts results by groups.
 893          $querydata->groupids = [$group1b->id, $group2a->id];
 894          $results = $this->search->search($querydata);
 895          $this->assert_result_titles(['F1SG-B', 'F1VG-B', 'F2SG-A'], $results);
 896  
 897          // Student enrolled in both courses but no groups.
 898          $student1 = $generator->create_user();
 899          $generator->enrol_user($student1->id, $course1->id, 'student');
 900          $generator->enrol_user($student1->id, $course2->id, 'student');
 901          $this->setUser($student1);
 902  
 903          unset($querydata->groupids);
 904          $results = $this->search->search($querydata);
 905          $this->assert_result_titles(['F1NG', 'F1VG-A', 'F1VG-B'], $results);
 906  
 907          // Student enrolled in both courses and group A in both cases.
 908          $student2 = $generator->create_user();
 909          $generator->enrol_user($student2->id, $course1->id, 'student');
 910          $generator->enrol_user($student2->id, $course2->id, 'student');
 911          groups_add_member($group1a, $student2);
 912          groups_add_member($group2a, $student2);
 913          $this->setUser($student2);
 914  
 915          $results = $this->search->search($querydata);
 916          $this->assert_result_titles(['F1NG', 'F1SG-A', 'F1VG-A', 'F1VG-B', 'F2SG-A'], $results);
 917  
 918          // Manually restrict results to group B in course 1.
 919          $querydata->groupids = [$group1b->id];
 920          $results = $this->search->search($querydata);
 921          $this->assert_result_titles(['F1VG-B'], $results);
 922  
 923          // Manually restrict results to group A in course 1.
 924          $querydata->groupids = [$group1a->id];
 925          $results = $this->search->search($querydata);
 926          $this->assert_result_titles(['F1SG-A', 'F1VG-A'], $results);
 927  
 928          // Manager enrolled in both courses (has access all groups).
 929          $manager = $generator->create_user();
 930          $generator->enrol_user($manager->id, $course1->id, 'manager');
 931          $generator->enrol_user($manager->id, $course2->id, 'manager');
 932          $this->setUser($manager);
 933          unset($querydata->groupids);
 934          $results = $this->search->search($querydata);
 935          $this->assert_result_titles(
 936                  ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results);
 937      }
 938  
 939      /**
 940       * Tests searching for results restricted to specific user id(s).
 941       */
 942      public function test_user_restriction() {
 943          // Use real search areas.
 944          $this->search->clear_static();
 945          $this->search->add_core_search_areas();
 946  
 947          // Create a course, a forum, and a glossary.
 948          $generator = $this->getDataGenerator();
 949          $course = $generator->create_course();
 950          $forum = $generator->create_module('forum', ['course' => $course->id]);
 951          $glossary = $generator->create_module('glossary', ['course' => $course->id]);
 952  
 953          // Create 3 user accounts, all enrolled as students on the course.
 954          $user1 = $generator->create_user();
 955          $user2 = $generator->create_user();
 956          $user3 = $generator->create_user();
 957          $generator->enrol_user($user1->id, $course->id, 'student');
 958          $generator->enrol_user($user2->id, $course->id, 'student');
 959          $generator->enrol_user($user3->id, $course->id, 'student');
 960  
 961          // All users create a forum discussion.
 962          $forumgen = $generator->get_plugin_generator('mod_forum');
 963          $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
 964              'userid' => $user1->id, 'name' => 'Post1', 'message' => 'plugh']);
 965          $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
 966                  'userid' => $user2->id, 'name' => 'Post2', 'message' => 'plugh']);
 967          $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
 968                  'userid' => $user3->id, 'name' => 'Post3', 'message' => 'plugh']);
 969  
 970          // Two of the users create entries in the glossary.
 971          $glossarygen = $generator->get_plugin_generator('mod_glossary');
 972          $glossarygen->create_content($glossary, ['concept' => 'Entry1', 'definition' => 'plugh',
 973                  'userid' => $user1->id]);
 974          $glossarygen->create_content($glossary, ['concept' => 'Entry3', 'definition' => 'plugh',
 975                  'userid' => $user3->id]);
 976  
 977          // Index the data.
 978          $this->search->index();
 979  
 980          // Search without user restriction should find everything.
 981          $querydata = new stdClass();
 982          $querydata->q = 'plugh';
 983          $results = $this->search->search($querydata);
 984          $this->assert_result_titles(
 985                  ['Entry1', 'Entry3', 'Post1', 'Post2', 'Post3'], $results);
 986  
 987          // Restriction to user 3 only.
 988          $querydata->userids = [$user3->id];
 989          $results = $this->search->search($querydata);
 990          $this->assert_result_titles(
 991                  ['Entry3', 'Post3'], $results);
 992  
 993          // Restriction to users 1 and 2.
 994          $querydata->userids = [$user1->id, $user2->id];
 995          $results = $this->search->search($querydata);
 996          $this->assert_result_titles(
 997                  ['Entry1', 'Post1', 'Post2'], $results);
 998  
 999          // Restriction to users 1 and 2 combined with context restriction.
1000          $querydata->contextids = [context_module::instance($glossary->cmid)->id];
1001          $results = $this->search->search($querydata);
1002          $this->assert_result_titles(
1003                  ['Entry1'], $results);
1004  
1005          // Restriction to users 1 and 2 combined with area restriction.
1006          unset($querydata->contextids);
1007          $querydata->areaids = [\core_search\manager::generate_areaid('mod_forum', 'post')];
1008          $results = $this->search->search($querydata);
1009          $this->assert_result_titles(
1010                  ['Post1', 'Post2'], $results);
1011      }
1012  
1013      /**
1014       * Tests searching for results containing words in italic text. (This used to fail.)
1015       */
1016      public function test_italics() {
1017          global $USER;
1018  
1019          // Use real search areas.
1020          $this->search->clear_static();
1021          $this->search->add_core_search_areas();
1022  
1023          // Create a course and a forum.
1024          $generator = $this->getDataGenerator();
1025          $course = $generator->create_course();
1026          $forum = $generator->create_module('forum', ['course' => $course->id]);
1027  
1028          // As admin user, create forum discussions with various words in italics or with underlines.
1029          $this->setAdminUser();
1030          $forumgen = $generator->get_plugin_generator('mod_forum');
1031          $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1032                  'userid' => $USER->id, 'name' => 'Post1',
1033                  'message' => '<p>This is a post about <i>frogs</i>.</p>']);
1034          $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1035                  'userid' => $USER->id, 'name' => 'Post2',
1036                  'message' => '<p>This is a post about <i>toads and zombies</i>.</p>']);
1037          $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1038                  'userid' => $USER->id, 'name' => 'Post3',
1039                  'message' => '<p>This is a post about toads_and_zombies.</p>']);
1040          $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1041                  'userid' => $USER->id, 'name' => 'Post4',
1042                  'message' => '<p>This is a post about _leading and trailing_ underlines.</p>']);
1043  
1044          // Index the data.
1045          $this->search->index();
1046  
1047          // Search for 'frogs' should find the post.
1048          $querydata = new stdClass();
1049          $querydata->q = 'frogs';
1050          $results = $this->search->search($querydata);
1051          $this->assert_result_titles(['Post1'], $results);
1052  
1053          // Search for 'toads' or 'zombies' should find post 2 (and not 3)...
1054          $querydata->q = 'toads';
1055          $results = $this->search->search($querydata);
1056          $this->assert_result_titles(['Post2'], $results);
1057          $querydata->q = 'zombies';
1058          $results = $this->search->search($querydata);
1059          $this->assert_result_titles(['Post2'], $results);
1060  
1061          // Search for 'toads_and_zombies' should find post 3.
1062          $querydata->q = 'toads_and_zombies';
1063          $results = $this->search->search($querydata);
1064          $this->assert_result_titles(['Post3'], $results);
1065  
1066          // Search for '_leading' or 'trailing_' should find post 4.
1067          $querydata->q = '_leading';
1068          $results = $this->search->search($querydata);
1069          $this->assert_result_titles(['Post4'], $results);
1070          $querydata->q = 'trailing_';
1071          $results = $this->search->search($querydata);
1072          $this->assert_result_titles(['Post4'], $results);
1073      }
1074  
1075      /**
1076       * Asserts that the returned documents have the expected titles (regardless of order).
1077       *
1078       * @param string[] $expected List of expected document titles
1079       * @param \core_search\document[] $results List of returned documents
1080       */
1081      protected function assert_result_titles(array $expected, array $results) {
1082          $titles = [];
1083          foreach ($results as $result) {
1084              $titles[] = $result->get('title');
1085          }
1086          sort($titles);
1087          sort($expected);
1088          $this->assertEquals($expected, $titles);
1089      }
1090  
1091      /**
1092       * Tests the get_supported_orders function for contexts where we can only use relevance
1093       * (system, category).
1094       */
1095      public function test_get_supported_orders_relevance_only() {
1096          global $DB;
1097  
1098          // System or category context: relevance only.
1099          $orders = $this->engine->get_supported_orders(\context_system::instance());
1100          $this->assertCount(1, $orders);
1101          $this->assertArrayHasKey('relevance', $orders);
1102  
1103          $categoryid = $DB->get_field_sql('SELECT MIN(id) FROM {course_categories}');
1104          $orders = $this->engine->get_supported_orders(\context_coursecat::instance($categoryid));
1105          $this->assertCount(1, $orders);
1106          $this->assertArrayHasKey('relevance', $orders);
1107      }
1108  
1109      /**
1110       * Tests the get_supported_orders function for contexts where we support location as well
1111       * (course, activity, block).
1112       */
1113      public function test_get_supported_orders_relevance_and_location() {
1114          global $DB;
1115  
1116          // Test with course context.
1117          $generator = $this->getDataGenerator();
1118          $course = $generator->create_course(['fullname' => 'Frogs']);
1119          $coursecontext = \context_course::instance($course->id);
1120  
1121          $orders = $this->engine->get_supported_orders($coursecontext);
1122          $this->assertCount(2, $orders);
1123          $this->assertArrayHasKey('relevance', $orders);
1124          $this->assertArrayHasKey('location', $orders);
1125          $this->assertContains('Course: Frogs', $orders['location']);
1126  
1127          // Test with activity context.
1128          $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'Toads']);
1129  
1130          $orders = $this->engine->get_supported_orders(\context_module::instance($page->cmid));
1131          $this->assertCount(2, $orders);
1132          $this->assertArrayHasKey('relevance', $orders);
1133          $this->assertArrayHasKey('location', $orders);
1134          $this->assertContains('Page: Toads', $orders['location']);
1135  
1136          // Test with block context.
1137          $instance = (object)['blockname' => 'html', 'parentcontextid' => $coursecontext->id,
1138                  'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*',
1139                  'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1,
1140                  'configdata' => ''];
1141          $blockid = $DB->insert_record('block_instances', $instance);
1142          $blockcontext = \context_block::instance($blockid);
1143  
1144          $orders = $this->engine->get_supported_orders($blockcontext);
1145          $this->assertCount(2, $orders);
1146          $this->assertArrayHasKey('relevance', $orders);
1147          $this->assertArrayHasKey('location', $orders);
1148          $this->assertContains('Block: HTML', $orders['location']);
1149      }
1150  
1151      /**
1152       * Tests ordering by relevance vs location.
1153       */
1154      public function test_ordering() {
1155          // Create 2 courses and 2 activities.
1156          $generator = $this->getDataGenerator();
1157          $course1 = $generator->create_course(['fullname' => 'Course 1']);
1158          $course1context = \context_course::instance($course1->id);
1159          $course1page = $generator->create_module('page', ['course' => $course1]);
1160          $course1pagecontext = \context_module::instance($course1page->cmid);
1161          $course2 = $generator->create_course(['fullname' => 'Course 2']);
1162          $course2context = \context_course::instance($course2->id);
1163          $course2page = $generator->create_module('page', ['course' => $course2]);
1164          $course2pagecontext = \context_module::instance($course2page->cmid);
1165  
1166          // Create one search record in each activity and course.
1167          $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy');
1168          $this->create_search_record($course1->id, $course1pagecontext->id, 'C1P', 'Xyzzy');
1169          $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy');
1170          $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh');
1171          $this->search->index();
1172  
1173          // Default search works by relevance so the one with both words should be top.
1174          $querydata = new stdClass();
1175          $querydata->q = 'xyzzy plugh';
1176          $results = $this->search->search($querydata);
1177          $this->assertCount(4, $results);
1178          $this->assertEquals('C2P', $results[0]->get('title'));
1179  
1180          // Same if you explicitly specify relevance.
1181          $querydata->order = 'relevance';
1182          $results = $this->search->search($querydata);
1183          $this->assertEquals('C2P', $results[0]->get('title'));
1184  
1185          // If you specify order by location and you are in C2 or C2P then results are the same.
1186          $querydata->order = 'location';
1187          $querydata->context = $course2context;
1188          $results = $this->search->search($querydata);
1189          $this->assertEquals('C2P', $results[0]->get('title'));
1190          $querydata->context = $course2pagecontext;
1191          $results = $this->search->search($querydata);
1192          $this->assertEquals('C2P', $results[0]->get('title'));
1193  
1194          // But if you are in C1P then you get different results (C1P first).
1195          $querydata->context = $course1pagecontext;
1196          $results = $this->search->search($querydata);
1197          $this->assertEquals('C1P', $results[0]->get('title'));
1198      }
1199  
1200      /**
1201       * Tests with bogus content (that can be entered into Moodle) to see if it crashes.
1202       */
1203      public function test_bogus_content() {
1204          $generator = $this->getDataGenerator();
1205          $course1 = $generator->create_course(['fullname' => 'Course 1']);
1206          $course1context = \context_course::instance($course1->id);
1207  
1208          // It is possible to enter into a Moodle database content containing these characters,
1209          // which are Unicode non-characters / byte order marks. If sent to Solr, these cause
1210          // failures.
1211          $boguscontent = html_entity_decode('&#xfffe;') . 'frog';
1212          $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1213          $boguscontent = html_entity_decode('&#xffff;') . 'frog';
1214          $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1215  
1216          // Unicode Standard Version 9.0 - Core Specification, section 23.7, lists 66 non-characters
1217          // in total. Here are some of them - these work OK for me but it may depend on platform.
1218          $boguscontent = html_entity_decode('&#xfdd0;') . 'frog';
1219          $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1220          $boguscontent = html_entity_decode('&#xfdef;') . 'frog';
1221          $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1222          $boguscontent = html_entity_decode('&#x1fffe;') . 'frog';
1223          $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1224          $boguscontent = html_entity_decode('&#x10ffff;') . 'frog';
1225          $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1226  
1227          // Do the indexing (this will check it doesn't throw warnings).
1228          $this->search->index();
1229  
1230          // Confirm that all 6 documents are found in search.
1231          $querydata = new stdClass();
1232          $querydata->q = 'frog';
1233          $results = $this->search->search($querydata);
1234          $this->assertCount(6, $results);
1235      }
1236  
1237      /**
1238       * Adds a record to the mock search area, so that the search engine can find it later.
1239       *
1240       * @param int $courseid Course id
1241       * @param int $contextid Context id
1242       * @param string $title Title for search index
1243       * @param string $content Content for search index
1244       */
1245      protected function create_search_record($courseid, $contextid, $title, $content) {
1246          $record = new \stdClass();
1247          $record->content = $content;
1248          $record->title = $title;
1249          $record->courseid = $courseid;
1250          $record->contextid = $contextid;
1251          $this->generator->create_record($record);
1252      }
1253  
1254      /**
1255       * Tries out deleting data for a context or a course.
1256       *
1257       * @throws coding_exception
1258       * @throws moodle_exception
1259       */
1260      public function test_deleted_contexts_and_courses() {
1261          // Create some courses and activities.
1262          $generator = $this->getDataGenerator();
1263          $course1 = $generator->create_course(['fullname' => 'Course 1']);
1264          $course1context = \context_course::instance($course1->id);
1265          $course1page1 = $generator->create_module('page', ['course' => $course1]);
1266          $course1page1context = \context_module::instance($course1page1->cmid);
1267          $course1page2 = $generator->create_module('page', ['course' => $course1]);
1268          $course1page2context = \context_module::instance($course1page2->cmid);
1269          $course2 = $generator->create_course(['fullname' => 'Course 2']);
1270          $course2context = \context_course::instance($course2->id);
1271          $course2page = $generator->create_module('page', ['course' => $course2]);
1272          $course2pagecontext = \context_module::instance($course2page->cmid);
1273  
1274          // Create one search record in each activity and course.
1275          $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy');
1276          $this->create_search_record($course1->id, $course1page1context->id, 'C1P1', 'Xyzzy');
1277          $this->create_search_record($course1->id, $course1page2context->id, 'C1P2', 'Xyzzy');
1278          $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy');
1279          $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh');
1280          $this->search->index();
1281  
1282          // By default we have all results.
1283          $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2', 'C2', 'C2P']);
1284  
1285          // Say we delete the course2pagecontext...
1286          $this->engine->delete_index_for_context($course2pagecontext->id);
1287          $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2', 'C2']);
1288  
1289          // Now delete the second course...
1290          $this->engine->delete_index_for_course($course2->id);
1291          $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2']);
1292  
1293          // Finally let's delete using Moodle functions to check that works. Single context first.
1294          course_delete_module($course1page1->cmid);
1295          $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P2']);
1296          delete_course($course1, false);
1297          $this->assert_raw_solr_query_result('content:xyzzy', []);
1298      }
1299  
1300      /**
1301       * Carries out a raw Solr query using the Solr basic query syntax.
1302       *
1303       * This is used to test data contained in the index without going through Moodle processing.
1304       *
1305       * @param string $q Search query
1306       * @param string[] $expected Expected titles of results, in alphabetical order
1307       */
1308      protected function assert_raw_solr_query_result(string $q, array $expected) {
1309          $solr = $this->engine->get_search_client_public();
1310          $query = new SolrQuery($q);
1311          $results = $solr->query($query)->getResponse()->response->docs;
1312          if ($results) {
1313              $titles = array_map(function($x) {
1314                  return $x->title;
1315              }, $results);
1316              sort($titles);
1317          } else {
1318              $titles = [];
1319          }
1320          $this->assertEquals($expected, $titles);
1321      }
1322  }