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   * Question external functions tests.
  19   *
  20   * @package    core_question
  21   * @category   external
  22   * @copyright  2016 Pau Ferrer <pau@moodle.com>
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   * @since      Moodle 3.1
  25   */
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  global $CFG;
  30  
  31  require_once($CFG->dirroot . '/webservice/tests/helpers.php');
  32  require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
  33  
  34  /**
  35   * Question external functions tests
  36   *
  37   * @package    core_question
  38   * @category   external
  39   * @copyright  2016 Pau Ferrer <pau@moodle.com>
  40   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  41   * @since      Moodle 3.1
  42   */
  43  class core_question_external_testcase extends externallib_advanced_testcase {
  44  
  45      /**
  46       * Set up for every test
  47       */
  48      public function setUp(): void {
  49          global $DB;
  50          $this->resetAfterTest();
  51          $this->setAdminUser();
  52  
  53          // Setup test data.
  54          $this->course = $this->getDataGenerator()->create_course();
  55  
  56          // Create users.
  57          $this->student = self::getDataGenerator()->create_user();
  58  
  59          // Users enrolments.
  60          $this->studentrole = $DB->get_record('role', array('shortname' => 'student'));
  61          $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
  62      }
  63  
  64      /**
  65       * Test update question flag
  66       */
  67      public function test_core_question_update_flag() {
  68  
  69          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
  70  
  71          // Create a question category.
  72          $cat = $questiongenerator->create_question_category();
  73  
  74          $quba = question_engine::make_questions_usage_by_activity('core_question_update_flag', context_system::instance());
  75          $quba->set_preferred_behaviour('deferredfeedback');
  76          $questiondata = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
  77          $question = question_bank::load_question($questiondata->id);
  78          $slot = $quba->add_question($question);
  79          $qa = $quba->get_question_attempt($slot);
  80  
  81          self::setUser($this->student);
  82  
  83          $quba->start_all_questions();
  84          question_engine::save_questions_usage_by_activity($quba);
  85  
  86          $qubaid = $quba->get_id();
  87          $questionid = $question->id;
  88          $qaid = $qa->get_database_id();
  89          $checksum = md5($qubaid . "_" . $this->student->secret . "_" . $questionid . "_" . $qaid . "_" . $slot);
  90  
  91          $flag = core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true);
  92          $this->assertTrue($flag['status']);
  93  
  94          // Test invalid checksum.
  95          try {
  96              // Using random_string to force failing.
  97              $checksum = md5($qubaid . "_" . random_string(11) . "_" . $questionid . "_" . $qaid . "_" . $slot);
  98  
  99              core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true);
 100              $this->fail('Exception expected due to invalid checksum.');
 101          } catch (moodle_exception $e) {
 102              $this->assertEquals('errorsavingflags', $e->errorcode);
 103          }
 104      }
 105  
 106      /**
 107       * submit_tags_form should throw an exception when the question id doesn't match
 108       * a question.
 109       */
 110      public function test_submit_tags_form_incorrect_question_id() {
 111          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 112          list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions();
 113          $questioncontext = context::instance_by_id($qcat->contextid);
 114          $editingcontext = $questioncontext;
 115          $question = $questions[0];
 116          // Generate an id for a question that doesn't exist.
 117          $missingquestionid = $questions[1]->id * 2;
 118          $question->id = $missingquestionid;
 119          $formdata = $this->generate_encoded_submit_tags_form_string($question, $qcat, $questioncontext, [], []);
 120  
 121          // We should receive an exception if the question doesn't exist.
 122          $this->expectException('moodle_exception');
 123          core_question_external::submit_tags_form($missingquestionid, $editingcontext->id, $formdata);
 124      }
 125  
 126      /**
 127       * submit_tags_form should throw an exception when the context id doesn't match
 128       * a context.
 129       */
 130      public function test_submit_tags_form_incorrect_context_id() {
 131          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 132          list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions();
 133          $questioncontext = context::instance_by_id($qcat->contextid);
 134          $editingcontext = $questioncontext;
 135          $question = $questions[0];
 136          // Generate an id for a context that doesn't exist.
 137          $missingcontextid = $editingcontext->id * 200;
 138          $formdata = $this->generate_encoded_submit_tags_form_string($question, $qcat, $questioncontext, [], []);
 139  
 140          // We should receive an exception if the question doesn't exist.
 141          $this->expectException('moodle_exception');
 142          core_question_external::submit_tags_form($question->id, $missingcontextid, $formdata);
 143      }
 144  
 145      /**
 146       * submit_tags_form should return false when tags are disabled.
 147       */
 148      public function test_submit_tags_form_tags_disabled() {
 149          global $CFG;
 150  
 151          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 152          list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions();
 153          $questioncontext = context::instance_by_id($qcat->contextid);
 154          $editingcontext = $questioncontext;
 155          $question = $questions[0];
 156          $user = $this->create_user_can_tag($course);
 157          $formdata = $this->generate_encoded_submit_tags_form_string($question, $qcat, $questioncontext, [], []);
 158  
 159          $this->setUser($user);
 160          $CFG->usetags = false;
 161          $result = core_question_external::submit_tags_form($question->id, $editingcontext->id, $formdata);
 162          $CFG->usetags = true;
 163  
 164          $this->assertFalse($result['status']);
 165      }
 166  
 167      /**
 168       * submit_tags_form should return false if the user does not have any capability
 169       * to tag the question.
 170       */
 171      public function test_submit_tags_form_no_tag_permissions() {
 172          global $DB;
 173  
 174          $generator = $this->getDataGenerator();
 175          $user = $generator->create_user();
 176          $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
 177          $questiongenerator = $generator->get_plugin_generator('core_question');
 178          list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions();
 179          $questioncontext = context::instance_by_id($qcat->contextid);
 180          $editingcontext = $questioncontext;
 181          $question = $questions[0];
 182          $formdata = $this->generate_encoded_submit_tags_form_string(
 183              $question,
 184              $qcat,
 185              $questioncontext,
 186              ['foo'],
 187              ['bar']
 188          );
 189  
 190          // Prohibit all of the tag capabilities.
 191          assign_capability('moodle/question:tagmine', CAP_PROHIBIT, $teacherrole->id, $questioncontext->id);
 192          assign_capability('moodle/question:tagall', CAP_PROHIBIT, $teacherrole->id, $questioncontext->id);
 193  
 194          $generator->enrol_user($user->id, $course->id, $teacherrole->id, 'manual');
 195          $user->ignoresesskey = true;
 196          $this->setUser($user);
 197  
 198          $result = core_question_external::submit_tags_form($question->id, $editingcontext->id, $formdata);
 199  
 200          $this->assertFalse($result['status']);
 201      }
 202  
 203      /**
 204       * submit_tags_form should return false if the user only has the capability to
 205       * tag their own questions and the question is not theirs.
 206       */
 207      public function test_submit_tags_form_tagmine_permission_non_owner_question() {
 208          global $DB;
 209  
 210          $generator = $this->getDataGenerator();
 211          $user = $generator->create_user();
 212          $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
 213          $questiongenerator = $generator->get_plugin_generator('core_question');
 214          list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions();
 215          $questioncontext = context::instance_by_id($qcat->contextid);
 216          $editingcontext = $questioncontext;
 217          $question = $questions[0];
 218          $formdata = $this->generate_encoded_submit_tags_form_string(
 219              $question,
 220              $qcat,
 221              $questioncontext,
 222              ['foo'],
 223              ['bar']
 224          );
 225  
 226          // Make sure the question isn't created by the user.
 227          $question->createdby = $user->id + 1;
 228  
 229          // Prohibit all of the tag capabilities.
 230          assign_capability('moodle/question:tagmine', CAP_ALLOW, $teacherrole->id, $questioncontext->id);
 231          assign_capability('moodle/question:tagall', CAP_PROHIBIT, $teacherrole->id, $questioncontext->id);
 232  
 233          $generator->enrol_user($user->id, $course->id, $teacherrole->id, 'manual');
 234          $user->ignoresesskey = true;
 235          $this->setUser($user);
 236  
 237          $result = core_question_external::submit_tags_form($question->id, $editingcontext->id, $formdata);
 238  
 239          $this->assertFalse($result['status']);
 240      }
 241  
 242      /**
 243       * Data provided for the submit_tags_form test to check that course tags are
 244       * only created in the correct editing and question context combinations.
 245       *
 246       * @return array Test cases
 247       */
 248      public function get_submit_tags_form_testcases() {
 249          return [
 250              'course - course' => [
 251                  'editingcontext' => 'course',
 252                  'questioncontext' => 'course',
 253                  'questiontags' => ['foo'],
 254                  'coursetags' => ['bar'],
 255                  'expectcoursetags' => false
 256              ],
 257              'course - course - empty tags' => [
 258                  'editingcontext' => 'course',
 259                  'questioncontext' => 'course',
 260                  'questiontags' => [],
 261                  'coursetags' => ['bar'],
 262                  'expectcoursetags' => false
 263              ],
 264              'course - course category' => [
 265                  'editingcontext' => 'course',
 266                  'questioncontext' => 'category',
 267                  'questiontags' => ['foo'],
 268                  'coursetags' => ['bar'],
 269                  'expectcoursetags' => true
 270              ],
 271              'course - system' => [
 272                  'editingcontext' => 'course',
 273                  'questioncontext' => 'system',
 274                  'questiontags' => ['foo'],
 275                  'coursetags' => ['bar'],
 276                  'expectcoursetags' => true
 277              ],
 278              'course category - course' => [
 279                  'editingcontext' => 'category',
 280                  'questioncontext' => 'course',
 281                  'questiontags' => ['foo'],
 282                  'coursetags' => ['bar'],
 283                  'expectcoursetags' => false
 284              ],
 285              'course category - course category' => [
 286                  'editingcontext' => 'category',
 287                  'questioncontext' => 'category',
 288                  'questiontags' => ['foo'],
 289                  'coursetags' => ['bar'],
 290                  'expectcoursetags' => false
 291              ],
 292              'course category - system' => [
 293                  'editingcontext' => 'category',
 294                  'questioncontext' => 'system',
 295                  'questiontags' => ['foo'],
 296                  'coursetags' => ['bar'],
 297                  'expectcoursetags' => false
 298              ],
 299              'system - course' => [
 300                  'editingcontext' => 'system',
 301                  'questioncontext' => 'course',
 302                  'questiontags' => ['foo'],
 303                  'coursetags' => ['bar'],
 304                  'expectcoursetags' => false
 305              ],
 306              'system - course category' => [
 307                  'editingcontext' => 'system',
 308                  'questioncontext' => 'category',
 309                  'questiontags' => ['foo'],
 310                  'coursetags' => ['bar'],
 311                  'expectcoursetags' => false
 312              ],
 313              'system - system' => [
 314                  'editingcontext' => 'system',
 315                  'questioncontext' => 'system',
 316                  'questiontags' => ['foo'],
 317                  'coursetags' => ['bar'],
 318                  'expectcoursetags' => false
 319              ],
 320          ];
 321      }
 322  
 323      /**
 324       * Tests that submit_tags_form only creates course tags when the correct combination
 325       * of editing context and question context is provided.
 326       *
 327       * Course tags can only be set on a course category or system context question that
 328       * is being editing in a course context.
 329       *
 330       * @dataProvider get_submit_tags_form_testcases()
 331       * @param string $editingcontext The type of the context the question is being edited in
 332       * @param string $questioncontext The type of the context the question belongs to
 333       * @param string[] $questiontags The tag names to set as question tags
 334       * @param string[] $coursetags The tag names to set as course tags
 335       * @param bool $expectcoursetags If the given course tags should have been set or not
 336       */
 337      public function test_submit_tags_form_context_combinations(
 338          $editingcontext,
 339          $questioncontext,
 340          $questiontags,
 341          $coursetags,
 342          $expectcoursetags
 343      ) {
 344          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 345          list ($category, $course, $qcat, $questions) = $questiongenerator->setup_course_and_questions($questioncontext);
 346          $coursecontext = context_course::instance($course->id);
 347          $questioncontext = context::instance_by_id($qcat->contextid);
 348  
 349          switch($editingcontext) {
 350              case 'system':
 351                  $editingcontext = context_system::instance();
 352                  break;
 353  
 354              case 'category':
 355                  $editingcontext = context_coursecat::instance($category->id);
 356                  break;
 357  
 358              default:
 359                  $editingcontext = context_course::instance($course->id);
 360          }
 361  
 362          $user = $this->create_user_can_tag($course);
 363          $question = $questions[0];
 364          $formdata = $this->generate_encoded_submit_tags_form_string(
 365              $question,
 366              $qcat,
 367              $questioncontext,
 368              $questiontags, // Question tags.
 369              $coursetags // Course tags.
 370          );
 371  
 372          $this->setUser($user);
 373  
 374          $result = core_question_external::submit_tags_form($question->id, $editingcontext->id, $formdata);
 375  
 376          $this->assertTrue($result['status']);
 377  
 378          $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
 379          $coursetagobjects = [];
 380          $questiontagobjects = [];
 381  
 382          if ($expectcoursetags) {
 383              // If the use case is expecting course tags to be created then split
 384              // the tags into course tags and question tags and ensure we have
 385              // the correct number of course tags.
 386  
 387              while ($tagobject = array_shift($tagobjects)) {
 388                  if ($tagobject->taginstancecontextid == $questioncontext->id) {
 389                      $questiontagobjects[] = $tagobject;
 390                  } else if ($tagobject->taginstancecontextid == $coursecontext->id) {
 391                      $coursetagobjects[] = $tagobject;
 392                  }
 393              }
 394  
 395              $this->assertCount(count($coursetags), $coursetagobjects);
 396          } else {
 397              $questiontagobjects = $tagobjects;
 398          }
 399  
 400          // Ensure the expected number of question tags was created.
 401          $this->assertCount(count($questiontags), $questiontagobjects);
 402  
 403          foreach ($questiontagobjects as $tagobject) {
 404              // If we have any question tags then make sure they are in the list
 405              // of expected tags and have the correct context.
 406              $this->assertContains($tagobject->name, $questiontags);
 407              $this->assertEquals($questioncontext->id, $tagobject->taginstancecontextid);
 408          }
 409  
 410          foreach ($coursetagobjects as $tagobject) {
 411              // If we have any course tags then make sure they are in the list
 412              // of expected course tags and have the correct context.
 413              $this->assertContains($tagobject->name, $coursetags);
 414              $this->assertEquals($coursecontext->id, $tagobject->taginstancecontextid);
 415          }
 416      }
 417  
 418      /**
 419       * Build the encoded form data expected by the submit_tags_form external function.
 420       *
 421       * @param  stdClass $question         The question record
 422       * @param  stdClass $questioncategory The question category record
 423       * @param  context  $questioncontext  Context for the question category
 424       * @param  array  $tags               A list of tag names for the question
 425       * @param  array  $coursetags         A list of course tag names for the question
 426       * @return string                    HTML encoded string of the data
 427       */
 428      protected function generate_encoded_submit_tags_form_string($question, $questioncategory,
 429              $questioncontext, $tags = [], $coursetags = []) {
 430          global $CFG;
 431  
 432          require_once($CFG->dirroot . '/question/type/tags_form.php');
 433  
 434          $data = [
 435              'id' => $question->id,
 436              'categoryid' => $questioncategory->id,
 437              'contextid' => $questioncontext->id,
 438              'questionname' => $question->name,
 439              'questioncategory' => $questioncategory->name,
 440              'context' => $questioncontext->get_context_name(false),
 441              'tags' => $tags,
 442              'coursetags' => $coursetags
 443          ];
 444          $data = core_question\form\tags::mock_generate_submit_keys($data);
 445  
 446          return http_build_query($data, '', '&');
 447      }
 448  
 449      /**
 450       * Create a user, enrol them in the course, and give them the capability to
 451       * tag all questions in the system context.
 452       *
 453       * @param  stdClass $course The course record to enrol in
 454       * @return stdClass         The user record
 455       */
 456      protected function create_user_can_tag($course) {
 457          global $DB;
 458  
 459          $generator = $this->getDataGenerator();
 460          $user = $generator->create_user();
 461          $roleid = $generator->create_role();
 462          $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
 463          $systemcontext = context_system::instance();
 464  
 465          $generator->role_assign($roleid, $user->id, $systemcontext->id);
 466          $generator->enrol_user($user->id, $course->id, $teacherrole->id, 'manual');
 467  
 468          // Give the user global ability to tag questions.
 469          assign_capability('moodle/question:tagall', CAP_ALLOW, $roleid, $systemcontext, true);
 470          // Allow the user to submit form data.
 471          $user->ignoresesskey = true;
 472  
 473          return $user;
 474      }
 475  
 476      /**
 477       * Data provider for the get_random_question_summaries test.
 478       */
 479      public function get_random_question_summaries_test_cases() {
 480          return [
 481              'empty category' => [
 482                  'categoryindex' => 'emptycat',
 483                  'includesubcategories' => false,
 484                  'usetagnames' => [],
 485                  'expectedquestionindexes' => []
 486              ],
 487              'single category' => [
 488                  'categoryindex' => 'cat1',
 489                  'includesubcategories' => false,
 490                  'usetagnames' => [],
 491                  'expectedquestionindexes' => ['cat1q1', 'cat1q2']
 492              ],
 493              'include sub category' => [
 494                  'categoryindex' => 'cat1',
 495                  'includesubcategories' => true,
 496                  'usetagnames' => [],
 497                  'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2']
 498              ],
 499              'single category with tags' => [
 500                  'categoryindex' => 'cat1',
 501                  'includesubcategories' => false,
 502                  'usetagnames' => ['cat1'],
 503                  'expectedquestionindexes' => ['cat1q1']
 504              ],
 505              'include sub category with tag on parent' => [
 506                  'categoryindex' => 'cat1',
 507                  'includesubcategories' => true,
 508                  'usetagnames' => ['cat1'],
 509                  'expectedquestionindexes' => ['cat1q1']
 510              ],
 511              'include sub category with tag on sub' => [
 512                  'categoryindex' => 'cat1',
 513                  'includesubcategories' => true,
 514                  'usetagnames' => ['subcat'],
 515                  'expectedquestionindexes' => ['subcatq1']
 516              ],
 517              'include sub category with same tag on parent and sub' => [
 518                  'categoryindex' => 'cat1',
 519                  'includesubcategories' => true,
 520                  'usetagnames' => ['foo'],
 521                  'expectedquestionindexes' => ['cat1q1', 'subcatq1']
 522              ],
 523              'include sub category with tag not matching' => [
 524                  'categoryindex' => 'cat1',
 525                  'includesubcategories' => true,
 526                  'usetagnames' => ['cat1', 'cat2'],
 527                  'expectedquestionindexes' => []
 528              ]
 529          ];
 530      }
 531  
 532      /**
 533       * Test the get_random_question_summaries function with various parameter combinations.
 534       *
 535       * This function creates a data set as follows:
 536       *      Category: cat1
 537       *          Question: cat1q1
 538       *              Tags: 'cat1', 'foo'
 539       *          Question: cat1q2
 540       *      Category: cat2
 541       *          Question: cat2q1
 542       *              Tags: 'cat2', 'foo'
 543       *          Question: cat2q2
 544       *      Category: subcat
 545       *          Question: subcatq1
 546       *              Tags: 'subcat', 'foo'
 547       *          Question: subcatq2
 548       *          Parent: cat1
 549       *      Category: emptycat
 550       *
 551       * @dataProvider get_random_question_summaries_test_cases()
 552       * @param string $categoryindex The named index for the category to use
 553       * @param bool $includesubcategories If the search should include subcategories
 554       * @param string[] $usetagnames The tag names to include in the search
 555       * @param string[] $expectedquestionindexes The questions expected in the result
 556       */
 557      public function test_get_random_question_summaries_variations(
 558          $categoryindex,
 559          $includesubcategories,
 560          $usetagnames,
 561          $expectedquestionindexes
 562      ) {
 563          $this->resetAfterTest();
 564  
 565          $context = context_system::instance();
 566          $categories = [];
 567          $questions = [];
 568          $tagnames = [
 569              'cat1',
 570              'cat2',
 571              'subcat',
 572              'foo'
 573          ];
 574          $collid = core_tag_collection::get_default();
 575          $tags = core_tag_tag::create_if_missing($collid, $tagnames);
 576          $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
 577  
 578          // First category and questions.
 579          list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat1', 'foo']);
 580          $categories['cat1'] = $category;
 581          $questions['cat1q1'] = $categoryquestions[0];
 582          $questions['cat1q2'] = $categoryquestions[1];
 583          // Second category and questions.
 584          list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat2', 'foo']);
 585          $categories['cat2'] = $category;
 586          $questions['cat2q1'] = $categoryquestions[0];
 587          $questions['cat2q2'] = $categoryquestions[1];
 588          // Sub category and questions.
 589          list($category, $categoryquestions) = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']);
 590          $categories['subcat'] = $category;
 591          $questions['subcatq1'] = $categoryquestions[0];
 592          $questions['subcatq2'] = $categoryquestions[1];
 593          // Empty category.
 594          list($category, $categoryquestions) = $this->create_category_and_questions(0);
 595          $categories['emptycat'] = $category;
 596  
 597          // Generate the arguments for the get_questions function.
 598          $category = $categories[$categoryindex];
 599          $tagids = array_map(function($tagname) use ($tags) {
 600              return $tags[$tagname]->id;
 601          }, $usetagnames);
 602  
 603          $result = core_question_external::get_random_question_summaries($category->id, $includesubcategories, $tagids, $context->id);
 604          $resultquestions = $result['questions'];
 605          $resulttotalcount = $result['totalcount'];
 606          // Generate the expected question set.
 607          $expectedquestions = array_map(function($index) use ($questions) {
 608              return $questions[$index];
 609          }, $expectedquestionindexes);
 610  
 611          // Ensure the resultquestions matches what was expected.
 612          $this->assertCount(count($expectedquestions), $resultquestions);
 613          $this->assertEquals(count($expectedquestions), $resulttotalcount);
 614          foreach ($expectedquestions as $question) {
 615              $this->assertEquals($resultquestions[$question->id]->id, $question->id);
 616              $this->assertEquals($resultquestions[$question->id]->category, $question->category);
 617          }
 618      }
 619  
 620      /**
 621       * get_random_question_summaries should throw an invalid_parameter_exception if not
 622       * given an integer for the category id.
 623       */
 624      public function test_get_random_question_summaries_invalid_category_id_param() {
 625          $this->resetAfterTest();
 626  
 627          $context = context_system::instance();
 628          $this->expectException('invalid_parameter_exception');
 629          core_question_external::get_random_question_summaries('invalid value', false, [], $context->id);
 630      }
 631  
 632      /**
 633       * get_random_question_summaries should throw an invalid_parameter_exception if not
 634       * given a boolean for the $includesubcategories parameter.
 635       */
 636      public function test_get_random_question_summaries_invalid_includesubcategories_param() {
 637          $this->resetAfterTest();
 638  
 639          $context = context_system::instance();
 640          $this->expectException('invalid_parameter_exception');
 641          core_question_external::get_random_question_summaries(1, 'invalid value', [], $context->id);
 642      }
 643  
 644      /**
 645       * get_random_question_summaries should throw an invalid_parameter_exception if not
 646       * given an array of integers for the tag ids parameter.
 647       */
 648      public function test_get_random_question_summaries_invalid_tagids_param() {
 649          $this->resetAfterTest();
 650  
 651          $context = context_system::instance();
 652          $this->expectException('invalid_parameter_exception');
 653          core_question_external::get_random_question_summaries(1, false, ['invalid', 'values'], $context->id);
 654      }
 655  
 656      /**
 657       * get_random_question_summaries should throw an invalid_parameter_exception if not
 658       * given a context.
 659       */
 660      public function test_get_random_question_summaries_invalid_context() {
 661          $this->resetAfterTest();
 662  
 663          $this->expectException('invalid_parameter_exception');
 664          core_question_external::get_random_question_summaries(1, false, [1, 2], 'context');
 665      }
 666  
 667      /**
 668       * get_random_question_summaries should throw an restricted_context_exception
 669       * if the given context is outside of the set of restricted contexts the user
 670       * is allowed to call external functions in.
 671       */
 672      public function test_get_random_question_summaries_restricted_context() {
 673          $this->resetAfterTest();
 674  
 675          $course = $this->getDataGenerator()->create_course();
 676          $coursecontext = context_course::instance($course->id);
 677          $systemcontext = context_system::instance();
 678          // Restrict access to external functions for the logged in user to only
 679          // the course we just created. External functions should not be allowed
 680          // to execute in any contexts above the course context.
 681          core_question_external::set_context_restriction($coursecontext);
 682  
 683          // An exception should be thrown when we try to execute at the system context
 684          // since we're restricted to the course context.
 685          try {
 686              // Do this in a try/catch statement to allow the context restriction
 687              // to be reset afterwards.
 688              core_question_external::get_random_question_summaries(1, false, [], $systemcontext->id);
 689          } catch (Exception $e) {
 690              $this->assertInstanceOf('restricted_context_exception', $e);
 691          }
 692          // Reset the restriction so that other tests don't fail aftwards.
 693          core_question_external::set_context_restriction($systemcontext);
 694      }
 695  
 696      /**
 697       * get_random_question_summaries should return a question that is formatted correctly.
 698       */
 699      public function test_get_random_question_summaries_formats_returned_questions() {
 700          $this->resetAfterTest();
 701  
 702          list($category, $questions) = $this->create_category_and_questions(1);
 703          $context = context_system::instance();
 704          $question = $questions[0];
 705          $expected = (object) [
 706              'id' => $question->id,
 707              'category' => $question->category,
 708              'parent' => $question->parent,
 709              'name' => $question->name,
 710              'qtype' => $question->qtype
 711          ];
 712  
 713          $result = core_question_external::get_random_question_summaries($category->id, false, [], $context->id);
 714          $actual = $result['questions'][$question->id];
 715  
 716          $this->assertEquals($expected->id, $actual->id);
 717          $this->assertEquals($expected->category, $actual->category);
 718          $this->assertEquals($expected->parent, $actual->parent);
 719          $this->assertEquals($expected->name, $actual->name);
 720          $this->assertEquals($expected->qtype, $actual->qtype);
 721          // These values are added by the formatting. It doesn't matter what the
 722          // exact values are just that they are returned.
 723          $this->assertObjectHasAttribute('icon', $actual);
 724          $this->assertObjectHasAttribute('key', $actual->icon);
 725          $this->assertObjectHasAttribute('component', $actual->icon);
 726          $this->assertObjectHasAttribute('alttext', $actual->icon);
 727      }
 728  
 729      /**
 730       * get_random_question_summaries should allow limiting and offsetting of the result set.
 731       */
 732      public function test_get_random_question_summaries_with_limit_and_offset() {
 733          $this->resetAfterTest();
 734          $numberofquestions = 5;
 735          $includesubcategories = false;
 736          $tagids = [];
 737          $limit = 1;
 738          $offset = 0;
 739          $context = context_system::instance();
 740          list($category, $questions) = $this->create_category_and_questions($numberofquestions);
 741  
 742          // Sort the questions by id to match the ordering of the result.
 743          usort($questions, function($a, $b) {
 744              $aid = $a->id;
 745              $bid = $b->id;
 746  
 747              if ($aid == $bid) {
 748                  return 0;
 749              }
 750              return $aid < $bid ? -1 : 1;
 751          });
 752  
 753          for ($i = 0; $i < $numberofquestions; $i++) {
 754              $result = core_question_external::get_random_question_summaries(
 755                  $category->id,
 756                  $includesubcategories,
 757                  $tagids,
 758                  $context->id,
 759                  $limit,
 760                  $offset
 761              );
 762  
 763              $resultquestions = $result['questions'];
 764              $totalcount = $result['totalcount'];
 765  
 766              $this->assertCount($limit, $resultquestions);
 767              $this->assertEquals($numberofquestions, $totalcount);
 768              $actual = array_shift($resultquestions);
 769              $expected = $questions[$i];
 770              $this->assertEquals($expected->id, $actual->id);
 771              $offset++;
 772          }
 773      }
 774  
 775      /**
 776       * get_random_question_summaries should throw an exception if the user doesn't
 777       * have the capability to use the questions in the requested category.
 778       */
 779      public function test_get_random_question_summaries_without_capability() {
 780          $this->resetAfterTest();
 781          $generator = $this->getDataGenerator();
 782          $user = $generator->create_user();
 783          $roleid = $generator->create_role();
 784          $systemcontext = context_system::instance();
 785          $numberofquestions = 5;
 786          $includesubcategories = false;
 787          $tagids = [];
 788          $context = context_system::instance();
 789          list($category, $questions) = $this->create_category_and_questions($numberofquestions);
 790          $categorycontext = context::instance_by_id($category->contextid);
 791  
 792          $generator->role_assign($roleid, $user->id, $systemcontext->id);
 793          // Prohibit all of the tag capabilities.
 794          assign_capability('moodle/question:viewall', CAP_PROHIBIT, $roleid, $categorycontext->id);
 795  
 796          $this->setUser($user);
 797          $this->expectException('moodle_exception');
 798          core_question_external::get_random_question_summaries(
 799              $category->id,
 800              $includesubcategories,
 801              $tagids,
 802              $context->id
 803          );
 804      }
 805  
 806      /**
 807       * Create a question category and create questions in that category. Tag
 808       * the first question in each category with the given tags.
 809       *
 810       * @param int $questioncount How many questions to create.
 811       * @param string[] $tagnames The list of tags to use.
 812       * @param stdClass|null $parentcategory The category to set as the parent of the created category.
 813       * @return array The category and questions.
 814       */
 815      protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null) {
 816          $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
 817  
 818          if ($parentcategory) {
 819              $catparams = ['parent' => $parentcategory->id];
 820          } else {
 821              $catparams = [];
 822          }
 823  
 824          $category = $generator->create_question_category($catparams);
 825          $questions = [];
 826  
 827          for ($i = 0; $i < $questioncount; $i++) {
 828              $questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]);
 829          }
 830  
 831          if (!empty($tagnames) && !empty($questions)) {
 832              $context = context::instance_by_id($category->contextid);
 833              core_tag_tag::set_item_tags('core_question', 'question', $questions[0]->id, $context, $tagnames);
 834          }
 835  
 836          return [$category, $questions];
 837      }
 838  }