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