Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

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