Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace core_question;
  18  
  19  use core_external\restricted_context_exception;
  20  use core_question_external;
  21  use externallib_advanced_testcase;
  22  
  23  defined('MOODLE_INTERNAL') || die();
  24  
  25  global $CFG;
  26  
  27  require_once($CFG->dirroot . '/webservice/tests/helpers.php');
  28  require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
  29  
  30  /**
  31   * Question external functions tests
  32   *
  33   * @package    core_question
  34   * @covers     \core_question_external
  35   * @category   external
  36   * @copyright  2016 Pau Ferrer <pau@moodle.com>
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   * @since      Moodle 3.1
  39   */
  40  class externallib_test extends externallib_advanced_testcase {
  41  
  42      /** @var \stdClass course record. */
  43      protected $course;
  44  
  45      /** @var \stdClass user record. */
  46      protected $student;
  47  
  48      /** @var \stdClass user role record. */
  49      protected $studentrole;
  50  
  51      /**
  52       * Set up for every test
  53       */
  54      public function setUp(): void {
  55          global $DB;
  56          $this->resetAfterTest();
  57          $this->setAdminUser();
  58  
  59          // Setup test data.
  60          $this->course = $this->getDataGenerator()->create_course();
  61  
  62          // Create users.
  63          $this->student = self::getDataGenerator()->create_user();
  64  
  65          // Users enrolments.
  66          $this->studentrole = $DB->get_record('role', ['shortname' => 'student']);
  67          $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
  68      }
  69  
  70      /**
  71       * Test update question flag
  72       */
  73      public function test_core_question_update_flag() {
  74  
  75          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
  76  
  77          // Create a question category.
  78          $cat = $questiongenerator->create_question_category();
  79  
  80          $quba = \question_engine::make_questions_usage_by_activity('core_question_update_flag', \context_system::instance());
  81          $quba->set_preferred_behaviour('deferredfeedback');
  82          $questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
  83          $question = \question_bank::load_question($questiondata->id);
  84          $slot = $quba->add_question($question);
  85          $qa = $quba->get_question_attempt($slot);
  86  
  87          self::setUser($this->student);
  88  
  89          $quba->start_all_questions();
  90          \question_engine::save_questions_usage_by_activity($quba);
  91  
  92          $qubaid = $quba->get_id();
  93          $questionid = $question->id;
  94          $qaid = $qa->get_database_id();
  95          $checksum = md5($qubaid . "_" . $this->student->secret . "_" . $questionid . "_" . $qaid . "_" . $slot);
  96  
  97          $flag = core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true);
  98          $this->assertTrue($flag['status']);
  99  
 100          // Test invalid checksum.
 101          try {
 102              // Using random_string to force failing.
 103              $checksum = md5($qubaid . "_" . random_string(11) . "_" . $questionid . "_" . $qaid . "_" . $slot);
 104  
 105              core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true);
 106              $this->fail('Exception expected due to invalid checksum.');
 107          } catch (\moodle_exception $e) {
 108              $this->assertEquals('errorsavingflags', $e->errorcode);
 109          }
 110      }
 111  
 112      /**
 113       * Data provider for the get_random_question_summaries test.
 114       */
 115      public function get_random_question_summaries_test_cases() {
 116          return [
 117              'empty category' => [
 118                  'categoryindex' => 'emptycat',
 119                  'includesubcategories' => false,
 120                  'usetagnames' => [],
 121                  'expectedquestionindexes' => []
 122              ],
 123              'single category' => [
 124                  'categoryindex' => 'cat1',
 125                  'includesubcategories' => false,
 126                  'usetagnames' => [],
 127                  'expectedquestionindexes' => ['cat1q1', 'cat1q2']
 128              ],
 129              'include sub category' => [
 130                  'categoryindex' => 'cat1',
 131                  'includesubcategories' => true,
 132                  'usetagnames' => [],
 133                  'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2']
 134              ],
 135              'single category with tags' => [
 136                  'categoryindex' => 'cat1',
 137                  'includesubcategories' => false,
 138                  'usetagnames' => ['cat1'],
 139                  'expectedquestionindexes' => ['cat1q1']
 140              ],
 141              'include sub category with tag on parent' => [
 142                  'categoryindex' => 'cat1',
 143                  'includesubcategories' => true,
 144                  'usetagnames' => ['cat1'],
 145                  'expectedquestionindexes' => ['cat1q1']
 146              ],
 147              'include sub category with tag on sub' => [
 148                  'categoryindex' => 'cat1',
 149                  'includesubcategories' => true,
 150                  'usetagnames' => ['subcat'],
 151                  'expectedquestionindexes' => ['subcatq1']
 152              ],
 153              'include sub category with same tag on parent and sub' => [
 154                  'categoryindex' => 'cat1',
 155                  'includesubcategories' => true,
 156                  'usetagnames' => ['foo'],
 157                  'expectedquestionindexes' => ['cat1q1', 'subcatq1']
 158              ],
 159              'include sub category with tag not matching' => [
 160                  'categoryindex' => 'cat1',
 161                  'includesubcategories' => true,
 162                  'usetagnames' => ['cat1', 'cat2'],
 163                  'expectedquestionindexes' => []
 164              ]
 165          ];
 166      }
 167  
 168      /**
 169       * Test the get_random_question_summaries function with various parameter combinations.
 170       *
 171       * This function creates a data set as follows:
 172       *      Category: cat1
 173       *          Question: cat1q1
 174       *              Tags: 'cat1', 'foo'
 175       *          Question: cat1q2
 176       *      Category: cat2
 177       *          Question: cat2q1
 178       *              Tags: 'cat2', 'foo'
 179       *          Question: cat2q2
 180       *      Category: subcat
 181       *          Question: subcatq1
 182       *              Tags: 'subcat', 'foo'
 183       *          Question: subcatq2
 184       *          Parent: cat1
 185       *      Category: emptycat
 186       *
 187       * @dataProvider get_random_question_summaries_test_cases()
 188       * @param string $categoryindex The named index for the category to use
 189       * @param bool $includesubcategories If the search should include subcategories
 190       * @param string[] $usetagnames The tag names to include in the search
 191       * @param string[] $expectedquestionindexes The questions expected in the result
 192       */
 193      public function test_get_random_question_summaries_variations(
 194          $categoryindex,
 195          $includesubcategories,
 196          $usetagnames,
 197          $expectedquestionindexes
 198      ) {
 199          $this->resetAfterTest();
 200  
 201          $context = \context_system::instance();
 202          $categories = [];
 203          $questions = [];
 204          $tagnames = [
 205              'cat1',
 206              'cat2',
 207              'subcat',
 208              'foo'
 209          ];
 210          $collid = \core_tag_collection::get_default();
 211          $tags = \core_tag_tag::create_if_missing($collid, $tagnames);
 212          $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
 213  
 214          // First category and questions.
 215          list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat1', 'foo']);
 216          $categories['cat1'] = $category;
 217          $questions['cat1q1'] = $categoryquestions[0];
 218          $questions['cat1q2'] = $categoryquestions[1];
 219          // Second category and questions.
 220          list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat2', 'foo']);
 221          $categories['cat2'] = $category;
 222          $questions['cat2q1'] = $categoryquestions[0];
 223          $questions['cat2q2'] = $categoryquestions[1];
 224          // Sub category and questions.
 225          list($category, $categoryquestions) = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']);
 226          $categories['subcat'] = $category;
 227          $questions['subcatq1'] = $categoryquestions[0];
 228          $questions['subcatq2'] = $categoryquestions[1];
 229          // Empty category.
 230          list($category, $categoryquestions) = $this->create_category_and_questions(0);
 231          $categories['emptycat'] = $category;
 232  
 233          // Generate the arguments for the get_questions function.
 234          $category = $categories[$categoryindex];
 235          $tagids = array_map(function($tagname) use ($tags) {
 236              return $tags[$tagname]->id;
 237          }, $usetagnames);
 238  
 239          $result = core_question_external::get_random_question_summaries($category->id, $includesubcategories, $tagids, $context->id);
 240          $resultquestions = $result['questions'];
 241          $resulttotalcount = $result['totalcount'];
 242          // Generate the expected question set.
 243          $expectedquestions = array_map(function($index) use ($questions) {
 244              return $questions[$index];
 245          }, $expectedquestionindexes);
 246  
 247          // Ensure the resultquestions matches what was expected.
 248          $this->assertCount(count($expectedquestions), $resultquestions);
 249          $this->assertEquals(count($expectedquestions), $resulttotalcount);
 250          foreach ($expectedquestions as $question) {
 251              $this->assertEquals($resultquestions[$question->id]->id, $question->id);
 252              $this->assertEquals($resultquestions[$question->id]->category, $question->category);
 253          }
 254      }
 255  
 256      /**
 257       * get_random_question_summaries should throw an invalid_parameter_exception if not
 258       * given an integer for the category id.
 259       */
 260      public function test_get_random_question_summaries_invalid_category_id_param() {
 261          $this->resetAfterTest();
 262  
 263          $context = \context_system::instance();
 264          $this->expectException('\invalid_parameter_exception');
 265          core_question_external::get_random_question_summaries('invalid value', false, [], $context->id);
 266      }
 267  
 268      /**
 269       * get_random_question_summaries should throw an invalid_parameter_exception if not
 270       * given a boolean for the $includesubcategories parameter.
 271       */
 272      public function test_get_random_question_summaries_invalid_includesubcategories_param() {
 273          $this->resetAfterTest();
 274  
 275          $context = \context_system::instance();
 276          $this->expectException('\invalid_parameter_exception');
 277          core_question_external::get_random_question_summaries(1, 'invalid value', [], $context->id);
 278      }
 279  
 280      /**
 281       * get_random_question_summaries should throw an invalid_parameter_exception if not
 282       * given an array of integers for the tag ids parameter.
 283       */
 284      public function test_get_random_question_summaries_invalid_tagids_param() {
 285          $this->resetAfterTest();
 286  
 287          $context = \context_system::instance();
 288          $this->expectException('\invalid_parameter_exception');
 289          core_question_external::get_random_question_summaries(1, false, ['invalid', 'values'], $context->id);
 290      }
 291  
 292      /**
 293       * get_random_question_summaries should throw an invalid_parameter_exception if not
 294       * given a context.
 295       */
 296      public function test_get_random_question_summaries_invalid_context() {
 297          $this->resetAfterTest();
 298  
 299          $this->expectException('\invalid_parameter_exception');
 300          core_question_external::get_random_question_summaries(1, false, [1, 2], 'context');
 301      }
 302  
 303      /**
 304       * get_random_question_summaries should throw an restricted_context_exception
 305       * if the given context is outside of the set of restricted contexts the user
 306       * is allowed to call external functions in.
 307       */
 308      public function test_get_random_question_summaries_restricted_context() {
 309          $this->resetAfterTest();
 310  
 311          $course = $this->getDataGenerator()->create_course();
 312          $coursecontext = \context_course::instance($course->id);
 313          $systemcontext = \context_system::instance();
 314          // Restrict access to external functions for the logged in user to only
 315          // the course we just created. External functions should not be allowed
 316          // to execute in any contexts above the course context.
 317          core_question_external::set_context_restriction($coursecontext);
 318  
 319          // An exception should be thrown when we try to execute at the system context
 320          // since we're restricted to the course context.
 321          try {
 322              // Do this in a try/catch statement to allow the context restriction
 323              // to be reset afterwards.
 324              core_question_external::get_random_question_summaries(1, false, [], $systemcontext->id);
 325          } catch (\Exception $e) {
 326              $this->assertInstanceOf(restricted_context_exception::class, $e);
 327          }
 328          // Reset the restriction so that other tests don't fail aftwards.
 329          core_question_external::set_context_restriction($systemcontext);
 330      }
 331  
 332      /**
 333       * get_random_question_summaries should return a question that is formatted correctly.
 334       */
 335      public function test_get_random_question_summaries_formats_returned_questions() {
 336          $this->resetAfterTest();
 337  
 338          list($category, $questions) = $this->create_category_and_questions(1);
 339          $context = \context_system::instance();
 340          $question = $questions[0];
 341          $expected = (object) [
 342              'id' => $question->id,
 343              'category' => $question->category,
 344              'parent' => $question->parent,
 345              'name' => $question->name,
 346              'qtype' => $question->qtype
 347          ];
 348  
 349          $result = core_question_external::get_random_question_summaries($category->id, false, [], $context->id);
 350          $actual = $result['questions'][$question->id];
 351  
 352          $this->assertEquals($expected->id, $actual->id);
 353          $this->assertEquals($expected->category, $actual->category);
 354          $this->assertEquals($expected->parent, $actual->parent);
 355          $this->assertEquals($expected->name, $actual->name);
 356          $this->assertEquals($expected->qtype, $actual->qtype);
 357          // These values are added by the formatting. It doesn't matter what the
 358          // exact values are just that they are returned.
 359          $this->assertObjectHasAttribute('icon', $actual);
 360          $this->assertObjectHasAttribute('key', $actual->icon);
 361          $this->assertObjectHasAttribute('component', $actual->icon);
 362          $this->assertObjectHasAttribute('alttext', $actual->icon);
 363      }
 364  
 365      /**
 366       * get_random_question_summaries should allow limiting and offsetting of the result set.
 367       */
 368      public function test_get_random_question_summaries_with_limit_and_offset() {
 369          $this->resetAfterTest();
 370          $numberofquestions = 5;
 371          $includesubcategories = false;
 372          $tagids = [];
 373          $limit = 1;
 374          $offset = 0;
 375          $context = \context_system::instance();
 376          list($category, $questions) = $this->create_category_and_questions($numberofquestions);
 377  
 378          // Sort the questions by id to match the ordering of the result.
 379          usort($questions, function($a, $b) {
 380              $aid = $a->id;
 381              $bid = $b->id;
 382  
 383              if ($aid == $bid) {
 384                  return 0;
 385              }
 386              return $aid < $bid ? -1 : 1;
 387          });
 388  
 389          for ($i = 0; $i < $numberofquestions; $i++) {
 390              $result = core_question_external::get_random_question_summaries(
 391                  $category->id,
 392                  $includesubcategories,
 393                  $tagids,
 394                  $context->id,
 395                  $limit,
 396                  $offset
 397              );
 398  
 399              $resultquestions = $result['questions'];
 400              $totalcount = $result['totalcount'];
 401  
 402              $this->assertCount($limit, $resultquestions);
 403              $this->assertEquals($numberofquestions, $totalcount);
 404              $actual = array_shift($resultquestions);
 405              $expected = $questions[$i];
 406              $this->assertEquals($expected->id, $actual->id);
 407              $offset++;
 408          }
 409      }
 410  
 411      /**
 412       * get_random_question_summaries should throw an exception if the user doesn't
 413       * have the capability to use the questions in the requested category.
 414       */
 415      public function test_get_random_question_summaries_without_capability() {
 416          $this->resetAfterTest();
 417          $generator = $this->getDataGenerator();
 418          $user = $generator->create_user();
 419          $roleid = $generator->create_role();
 420          $systemcontext = \context_system::instance();
 421          $numberofquestions = 5;
 422          $includesubcategories = false;
 423          $tagids = [];
 424          $context = \context_system::instance();
 425          list($category, $questions) = $this->create_category_and_questions($numberofquestions);
 426          $categorycontext = \context::instance_by_id($category->contextid);
 427  
 428          $generator->role_assign($roleid, $user->id, $systemcontext->id);
 429          // Prohibit all of the tag capabilities.
 430          assign_capability('moodle/question:viewall', CAP_PROHIBIT, $roleid, $categorycontext->id);
 431  
 432          $this->setUser($user);
 433          $this->expectException('moodle_exception');
 434          core_question_external::get_random_question_summaries(
 435              $category->id,
 436              $includesubcategories,
 437              $tagids,
 438              $context->id
 439          );
 440      }
 441  
 442      /**
 443       * Create a question category and create questions in that category. Tag
 444       * the first question in each category with the given tags.
 445       *
 446       * @param int $questioncount How many questions to create.
 447       * @param string[] $tagnames The list of tags to use.
 448       * @param stdClass|null $parentcategory The category to set as the parent of the created category.
 449       * @return array The category and questions.
 450       */
 451      protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null) {
 452          $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
 453  
 454          if ($parentcategory) {
 455              $catparams = ['parent' => $parentcategory->id];
 456          } else {
 457              $catparams = [];
 458          }
 459  
 460          $category = $generator->create_question_category($catparams);
 461          $questions = [];
 462  
 463          for ($i = 0; $i < $questioncount; $i++) {
 464              $questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]);
 465          }
 466  
 467          if (!empty($tagnames) && !empty($questions)) {
 468              $context = \context::instance_by_id($category->contextid);
 469              \core_tag_tag::set_item_tags('core_question', 'question', $questions[0]->id, $context, $tagnames);
 470          }
 471  
 472          return [$category, $questions];
 473      }
 474  }