Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 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 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]

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