Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 401 and 402] [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 mod_quiz;
  18  
  19  defined('MOODLE_INTERNAL') || die();
  20  
  21  global $CFG;
  22  require_once (__DIR__ . '/quiz_question_helper_test_trait.php');
  23  require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
  24  require_once($CFG->dirroot . '/mod/quiz/locallib.php');
  25  
  26  /**
  27   * Quiz backup and restore tests.
  28   *
  29   * @package    mod_quiz
  30   * @category   test
  31   * @copyright  2021 Catalyst IT Australia Pty Ltd
  32   * @author     Safat Shahin <safatshahin@catalyst-au.net>
  33   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34   * @coversDefaultClass \mod_quiz\question\bank\qbank_helper
  35   * @coversDefaultClass \backup_quiz_activity_structure_step
  36   * @coversDefaultClass \restore_quiz_activity_structure_step
  37   */
  38  class quiz_question_restore_test extends \advanced_testcase {
  39      use \quiz_question_helper_test_trait;
  40  
  41      /**
  42       * @var \stdClass test student user.
  43       */
  44      protected $student;
  45  
  46      /**
  47       * Called before every test.
  48       */
  49      public function setUp(): void {
  50          global $USER;
  51          parent::setUp();
  52          $this->setAdminUser();
  53          $this->course = $this->getDataGenerator()->create_course();
  54          $this->student = $this->getDataGenerator()->create_user();
  55          $this->user = $USER;
  56      }
  57  
  58      /**
  59       * Test a quiz backup and restore in a different course without attempts for course question bank.
  60       *
  61       * @covers ::get_question_structure
  62       */
  63      public function test_quiz_restore_in_a_different_course_using_course_question_bank() {
  64          $this->resetAfterTest();
  65  
  66          // Create the test quiz.
  67          $quiz = $this->create_test_quiz($this->course);
  68          $oldquizcontext = \context_module::instance($quiz->cmid);
  69          // Test for questions from a different context.
  70          $coursecontext = \context_course::instance($this->course->id);
  71          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
  72          $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $coursecontext->id]);
  73          $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $coursecontext->id]);
  74  
  75          // Make the backup.
  76          $backupid = $this->backup_quiz($quiz, $this->user);
  77  
  78          // Delete the current course to make sure there is no data.
  79          delete_course($this->course, false);
  80  
  81          // Check if the questions and associated data are deleted properly.
  82          $this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure(
  83                  $quiz->id, $oldquizcontext)));
  84  
  85          // Restore the course.
  86          $newcourse = $this->getDataGenerator()->create_course();
  87          $this->restore_quiz($backupid, $newcourse, $this->user);
  88  
  89          // Verify.
  90          $modules = get_fast_modinfo($newcourse->id)->get_instances_of('quiz');
  91          $module = reset($modules);
  92          $questions = \mod_quiz\question\bank\qbank_helper::get_question_structure(
  93                  $module->instance, $module->context);
  94          $this->assertCount(3, $questions);
  95      }
  96  
  97      /**
  98       * Test a quiz backup and restore in a different course without attempts for quiz question bank.
  99       *
 100       * @covers ::get_question_structure
 101       */
 102      public function test_quiz_restore_in_a_different_course_using_quiz_question_bank() {
 103          $this->resetAfterTest();
 104  
 105          // Create the test quiz.
 106          $quiz = $this->create_test_quiz($this->course);
 107          // Test for questions from a different context.
 108          $quizcontext = \context_module::instance($quiz->cmid);
 109          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 110          $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $quizcontext->id]);
 111          $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $quizcontext->id]);
 112  
 113          // Make the backup.
 114          $backupid = $this->backup_quiz($quiz, $this->user);
 115  
 116          // Delete the current course to make sure there is no data.
 117          delete_course($this->course, false);
 118  
 119          // Check if the questions and associated datas are deleted properly.
 120          $this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure(
 121                  $quiz->id, $quizcontext)));
 122  
 123          // Restore the course.
 124          $newcourse = $this->getDataGenerator()->create_course();
 125          $this->restore_quiz($backupid, $newcourse, $this->user);
 126  
 127          // Verify.
 128          $modules = get_fast_modinfo($newcourse->id)->get_instances_of('quiz');
 129          $module = reset($modules);
 130          $this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure(
 131                  $module->instance, $module->context)));
 132      }
 133  
 134      /**
 135       * Count the questions for the context.
 136       *
 137       * @param int $contextid
 138       * @param string $extracondition
 139       * @return int the number of questions.
 140       */
 141      protected function question_count(int $contextid, string $extracondition = ''): int {
 142          global $DB;
 143          return $DB->count_records_sql(
 144              "SELECT COUNT(q.id)
 145                 FROM {question} q
 146                 JOIN {question_versions} qv ON qv.questionid = q.id
 147                 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
 148                 JOIN {question_categories} qc on qc.id = qbe.questioncategoryid
 149                WHERE qc.contextid = ?
 150                $extracondition", [$contextid]);
 151      }
 152  
 153      /**
 154       * Test if a duplicate does not duplicate questions in course question bank.
 155       *
 156       * @covers ::duplicate_module
 157       */
 158      public function test_quiz_duplicate_does_not_duplicate_course_question_bank_questions() {
 159          $this->resetAfterTest();
 160          $quiz = $this->create_test_quiz($this->course);
 161          // Test for questions from a different context.
 162          $context = \context_course::instance($this->course->id);
 163          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 164          $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
 165          $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $context->id]);
 166          // Count the questions in course context.
 167          $this->assertEquals(7, $this->question_count($context->id));
 168          $newquiz = $this->duplicate_quiz($this->course, $quiz);
 169          $this->assertEquals(7, $this->question_count($context->id));
 170          $context = \context_module::instance($newquiz->id);
 171          // Count the questions in the quiz context.
 172          $this->assertEquals(0, $this->question_count($context->id));
 173      }
 174  
 175      /**
 176       * Test quiz duplicate for quiz question bank.
 177       *
 178       * @covers ::duplicate_module
 179       */
 180      public function test_quiz_duplicate_for_quiz_question_bank_questions() {
 181          $this->resetAfterTest();
 182          $quiz = $this->create_test_quiz($this->course);
 183          // Test for questions from a different context.
 184          $context = \context_module::instance($quiz->cmid);
 185          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 186          $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
 187          $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $context->id]);
 188          // Count the questions in course context.
 189          $this->assertEquals(7, $this->question_count($context->id));
 190          $newquiz = $this->duplicate_quiz($this->course, $quiz);
 191          $this->assertEquals(7, $this->question_count($context->id));
 192          $context = \context_module::instance($newquiz->id);
 193          // Count the questions in the quiz context.
 194          $this->assertEquals(7, $this->question_count($context->id));
 195      }
 196  
 197      /**
 198       * Test quiz restore with attempts.
 199       *
 200       * @covers ::get_question_structure
 201       */
 202      public function test_quiz_restore_with_attempts() {
 203          $this->resetAfterTest();
 204  
 205          // Create a quiz.
 206          $quiz = $this->create_test_quiz($this->course);
 207          $quizcontext = \context_module::instance($quiz->cmid);
 208          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 209          $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $quizcontext->id]);
 210          $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $quizcontext->id]);
 211  
 212          // Attempt it as a student, and check.
 213          /** @var \question_usage_by_activity $quba */
 214          [, $quba] = $this->attempt_quiz($quiz, $this->student);
 215          $this->assertEquals(3, $quba->question_count());
 216          $this->assertCount(1, quiz_get_user_attempts($quiz->id, $this->student->id));
 217  
 218          // Make the backup.
 219          $backupid = $this->backup_quiz($quiz, $this->user);
 220  
 221          // Delete the current course to make sure there is no data.
 222          delete_course($this->course, false);
 223  
 224          // Restore the backup.
 225          $newcourse = $this->getDataGenerator()->create_course();
 226          $this->restore_quiz($backupid, $newcourse, $this->user);
 227  
 228          // Verify.
 229          $modules = get_fast_modinfo($newcourse->id)->get_instances_of('quiz');
 230          $module = reset($modules);
 231          $this->assertCount(1, quiz_get_user_attempts($module->instance, $this->student->id));
 232          $this->assertCount(3, \mod_quiz\question\bank\qbank_helper::get_question_structure(
 233                  $module->instance, $module->context));
 234      }
 235  
 236      /**
 237       * Test pre 4.0 quiz restore for regular questions.
 238       *
 239       * @covers ::process_quiz_question_legacy_instance
 240       */
 241      public function test_pre_4_quiz_restore_for_regular_questions() {
 242          global $USER, $DB;
 243          $this->resetAfterTest();
 244          $backupid = 'abc';
 245          $backuppath = make_backup_temp_directory($backupid);
 246          get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
 247              __DIR__ . "/fixtures/moodle_28_quiz.mbz", $backuppath);
 248  
 249          // Do the restore to new course with default settings.
 250          $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
 251          $newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid);
 252          $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
 253              \backup::TARGET_NEW_COURSE);
 254  
 255          $this->assertTrue($rc->execute_precheck());
 256          $rc->execute_plan();
 257          $rc->destroy();
 258  
 259          // Get the information about the resulting course and check that it is set up correctly.
 260          $modinfo = get_fast_modinfo($newcourseid);
 261          $quiz = array_values($modinfo->get_instances_of('quiz'))[0];
 262          $quizobj = \quiz::create($quiz->instance);
 263          $structure = structure::create_for_quiz($quizobj);
 264  
 265          // Are the correct slots returned?
 266          $slots = $structure->get_slots();
 267          $this->assertCount(2, $slots);
 268  
 269          $quizobj->preload_questions();
 270          $quizobj->load_questions();
 271          $questions = $quizobj->get_questions();
 272          $this->assertCount(2, $questions);
 273  
 274          // Count the questions in quiz qbank.
 275          $this->assertEquals(2, $this->question_count($quizobj->get_context()->id));
 276      }
 277  
 278      /**
 279       * Test pre 4.0 quiz restore for random questions.
 280       *
 281       * @covers ::process_quiz_question_legacy_instance
 282       */
 283      public function test_pre_4_quiz_restore_for_random_questions() {
 284          global $USER, $DB;
 285          $this->resetAfterTest();
 286  
 287          $backupid = 'abc';
 288          $backuppath = make_backup_temp_directory($backupid);
 289          get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
 290              __DIR__ . "/fixtures/random_by_tag_quiz.mbz", $backuppath);
 291  
 292          // Do the restore to new course with default settings.
 293          $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
 294          $newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid);
 295          $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
 296              \backup::TARGET_NEW_COURSE);
 297  
 298          $this->assertTrue($rc->execute_precheck());
 299          $rc->execute_plan();
 300          $rc->destroy();
 301  
 302          // Get the information about the resulting course and check that it is set up correctly.
 303          $modinfo = get_fast_modinfo($newcourseid);
 304          $quiz = array_values($modinfo->get_instances_of('quiz'))[0];
 305          $quizobj = \quiz::create($quiz->instance);
 306          $structure = structure::create_for_quiz($quizobj);
 307  
 308          // Are the correct slots returned?
 309          $slots = $structure->get_slots();
 310          $this->assertCount(1, $slots);
 311  
 312          $quizobj->preload_questions();
 313          $quizobj->load_questions();
 314          $questions = $quizobj->get_questions();
 315          $this->assertCount(1, $questions);
 316  
 317          // Count the questions for course question bank.
 318          $this->assertEquals(6, $this->question_count(\context_course::instance($newcourseid)->id));
 319          $this->assertEquals(6, $this->question_count(\context_course::instance($newcourseid)->id,
 320              "AND q.qtype <> 'random'"));
 321  
 322          // Count the questions in quiz qbank.
 323          $this->assertEquals(0, $this->question_count($quizobj->get_context()->id));
 324      }
 325  
 326      /**
 327       * Test pre 4.0 quiz restore for random question tags.
 328       *
 329       * @covers ::process_quiz_question_legacy_instance
 330       */
 331      public function test_pre_4_quiz_restore_for_random_question_tags() {
 332          global $USER, $DB;
 333          $this->resetAfterTest();
 334          $randomtags = [
 335              '1' => ['first question', 'one', 'number one'],
 336              '2' => ['first question', 'one', 'number one'],
 337              '3' => ['one', 'number one', 'second question'],
 338          ];
 339          $backupid = 'abc';
 340          $backuppath = make_backup_temp_directory($backupid);
 341          get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
 342              __DIR__ . "/fixtures/moodle_311_quiz.mbz", $backuppath);
 343  
 344          // Do the restore to new course with default settings.
 345          $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
 346          $newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid);
 347          $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
 348              \backup::TARGET_NEW_COURSE);
 349  
 350          $this->assertTrue($rc->execute_precheck());
 351          $rc->execute_plan();
 352          $rc->destroy();
 353  
 354          // Get the information about the resulting course and check that it is set up correctly.
 355          $modinfo = get_fast_modinfo($newcourseid);
 356          $quiz = array_values($modinfo->get_instances_of('quiz'))[0];
 357          $quizobj = \quiz::create($quiz->instance);
 358          $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 359  
 360          // Count the questions in quiz qbank.
 361          $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id);
 362          $this->assertEquals(2, $this->question_count($context->id));
 363  
 364          // Are the correct slots returned?
 365          $slots = $structure->get_slots();
 366          $this->assertCount(3, $slots);
 367  
 368          // Check if the tags match with the actual restored data.
 369          foreach ($slots as $slot) {
 370              $setreference = $DB->get_record('question_set_references',
 371                  ['itemid' => $slot->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']);
 372              $filterconditions = json_decode($setreference->filtercondition);
 373              $tags = [];
 374              foreach ($filterconditions->tags as $tagstring) {
 375                  $tag = explode(',', $tagstring);
 376                  $tags[] = $tag[1];
 377              }
 378              $this->assertEquals([], array_diff($randomtags[$slot->slot], $tags));
 379          }
 380  
 381      }
 382  
 383      /**
 384       * Test pre 4.0 quiz restore for random question used on multiple quizzes.
 385       *
 386       * @covers ::process_quiz_question_legacy_instance
 387       */
 388      public function test_pre_4_quiz_restore_shared_random_question() {
 389          global $USER, $DB;
 390          $this->resetAfterTest();
 391  
 392          $backupid = 'abc';
 393          $backuppath = make_backup_temp_directory($backupid);
 394          get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
 395                  __DIR__ . "/fixtures/pre-40-shared-random-question.mbz", $backuppath);
 396  
 397          // Do the restore to new course with default settings.
 398          $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
 399          $newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid);
 400          $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
 401                  \backup::TARGET_NEW_COURSE);
 402  
 403          $this->assertTrue($rc->execute_precheck());
 404          $rc->execute_plan();
 405          $rc->destroy();
 406  
 407          // Get the information about the resulting course and check that it is set up correctly.
 408          // Each quiz should contain an instance of the random question.
 409          $modinfo = get_fast_modinfo($newcourseid);
 410          $quizzes = $modinfo->get_instances_of('quiz');
 411          $this->assertCount(2, $quizzes);
 412          foreach ($quizzes as $quiz) {
 413              $quizobj = \quiz::create($quiz->instance);
 414              $structure = structure::create_for_quiz($quizobj);
 415  
 416              // Are the correct slots returned?
 417              $slots = $structure->get_slots();
 418              $this->assertCount(1, $slots);
 419  
 420              $quizobj->preload_questions();
 421              $quizobj->load_questions();
 422              $questions = $quizobj->get_questions();
 423              $this->assertCount(1, $questions);
 424          }
 425  
 426          // Count the questions for course question bank.
 427          // We should have a single question, the random question should have been deleted after the restore.
 428          $this->assertEquals(1, $this->question_count(\context_course::instance($newcourseid)->id));
 429          $this->assertEquals(1, $this->question_count(\context_course::instance($newcourseid)->id,
 430                  "AND q.qtype <> 'random'"));
 431  
 432          // Count the questions in quiz qbank.
 433          $this->assertEquals(0, $this->question_count($quizobj->get_context()->id));
 434      }
 435  
 436      /**
 437       * Ensure that question slots are correctly backed up and restored with all properties.
 438       *
 439       * @covers \backup_quiz_activity_structure_step::define_structure()
 440       * @return void
 441       */
 442      public function test_backup_restore_question_slots(): void {
 443          $this->resetAfterTest(true);
 444  
 445          $course1 = $this->getDataGenerator()->create_course();
 446          $course2 = $this->getDataGenerator()->create_course();
 447  
 448          $user1 = $this->getDataGenerator()->create_and_enrol($course1, 'editingteacher');
 449          $this->getDataGenerator()->enrol_user($user1->id, $course2->id, 'editingteacher');
 450  
 451          // Make a quiz.
 452          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
 453  
 454          $quiz = $quizgenerator->create_instance(['course' => $course1->id, 'questionsperpage' => 0, 'grade' => 100.0,
 455                  'sumgrades' => 3]);
 456  
 457          // Create some fixed and random questions.
 458          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 459  
 460          $cat = $questiongenerator->create_question_category();
 461          $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
 462          $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
 463          $matchq = $questiongenerator->create_question('match', null, ['category' => $cat->id]);
 464          $randomcat = $questiongenerator->create_question_category();
 465          $questiongenerator->create_question('shortanswer', null, ['category' => $randomcat->id]);
 466          $questiongenerator->create_question('numerical', null, ['category' => $randomcat->id]);
 467          $questiongenerator->create_question('match', null, ['category' => $randomcat->id]);
 468  
 469          // Add them to the quiz.
 470          quiz_add_quiz_question($saq->id, $quiz, 1, 3);
 471          quiz_add_quiz_question($numq->id, $quiz, 2, 2);
 472          quiz_add_quiz_question($matchq->id, $quiz, 3, 1);
 473          quiz_add_random_questions($quiz, 3, $randomcat->id, 2, false);
 474  
 475          $quizobj = \quiz::create($quiz->id, $user1->id);
 476          $originalstructure = \mod_quiz\structure::create_for_quiz($quizobj);
 477          $originalslots = $originalstructure->get_slots();
 478  
 479          // Set one slot to requireprevious.
 480          $lastslot = end($originalslots);
 481          $originalstructure->update_question_dependency($lastslot->id, true);
 482  
 483          // Backup and restore the quiz.
 484          $backupid = $this->backup_quiz($quiz, $user1);
 485          $this->restore_quiz($backupid, $course2, $user1);
 486  
 487          // Ensure the restored slots match the original slots.
 488          $modinfo = get_fast_modinfo($course2);
 489          $quizzes = $modinfo->get_instances_of('quiz');
 490          $restoredquiz = reset($quizzes);
 491          $restoredquizobj = \quiz::create($restoredquiz->instance, $user1->id);
 492          $restoredstructure = \mod_quiz\structure::create_for_quiz($restoredquizobj);
 493          $restoredslots = array_values($restoredstructure->get_slots());
 494          $originalstructure = \mod_quiz\structure::create_for_quiz($quizobj);
 495          $originalslots = array_values($originalstructure->get_slots());
 496          foreach ($restoredslots as $key => $restoredslot) {
 497              $originalslot = $originalslots[$key];
 498              $this->assertEquals($originalslot->quizid, $quiz->id);
 499              $this->assertEquals($restoredslot->quizid, $restoredquiz->instance);
 500              $this->assertEquals($originalslot->slot, $restoredslot->slot);
 501              $this->assertEquals($originalslot->page, $restoredslot->page);
 502              $this->assertEquals($originalslot->requireprevious, $restoredslot->requireprevious);
 503              $this->assertEquals($originalslot->maxmark, $restoredslot->maxmark);
 504          }
 505      }
 506  }