Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 311 and 400] [Versions 39 and 400] [Versions 400 and 401] [Versions 400 and 402] [Versions 400 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  /**
  18   * Quiz module external functions tests.
  19   *
  20   * @package    mod_quiz
  21   * @category   external
  22   * @copyright  2016 Juan Leyva <juan@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 mod_quiz\external;
  28  
  29  use core_question\local\bank\question_version_status;
  30  use externallib_advanced_testcase;
  31  use mod_quiz_external;
  32  use mod_quiz_display_options;
  33  use quiz;
  34  use quiz_attempt;
  35  
  36  defined('MOODLE_INTERNAL') || die();
  37  
  38  global $CFG;
  39  
  40  require_once($CFG->dirroot . '/webservice/tests/helpers.php');
  41  
  42  /**
  43   * Silly class to access mod_quiz_external internal methods.
  44   *
  45   * @package mod_quiz
  46   * @copyright 2016 Juan Leyva <juan@moodle.com>
  47   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  48   * @since  Moodle 3.1
  49   */
  50  class testable_mod_quiz_external extends mod_quiz_external {
  51  
  52      /**
  53       * Public accessor.
  54       *
  55       * @param  array $params Array of parameters including the attemptid and preflight data
  56       * @param  bool $checkaccessrules whether to check the quiz access rules or not
  57       * @param  bool $failifoverdue whether to return error if the attempt is overdue
  58       * @return  array containing the attempt object and access messages
  59       */
  60      public static function validate_attempt($params, $checkaccessrules = true, $failifoverdue = true) {
  61          return parent::validate_attempt($params, $checkaccessrules, $failifoverdue);
  62      }
  63  
  64      /**
  65       * Public accessor.
  66       *
  67       * @param  array $params Array of parameters including the attemptid
  68       * @return  array containing the attempt object and display options
  69       */
  70      public static function validate_attempt_review($params) {
  71          return parent::validate_attempt_review($params);
  72      }
  73  }
  74  
  75  /**
  76   * Quiz module external functions tests
  77   *
  78   * @package    mod_quiz
  79   * @category   external
  80   * @copyright  2016 Juan Leyva <juan@moodle.com>
  81   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  82   * @since      Moodle 3.1
  83   */
  84  class external_test extends externallib_advanced_testcase {
  85  
  86      /**
  87       * Set up for every test
  88       */
  89      public function setUp(): void {
  90          global $DB;
  91          $this->resetAfterTest();
  92          $this->setAdminUser();
  93  
  94          // Setup test data.
  95          $this->course = $this->getDataGenerator()->create_course();
  96          $this->quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $this->course->id));
  97          $this->context = \context_module::instance($this->quiz->cmid);
  98          $this->cm = get_coursemodule_from_instance('quiz', $this->quiz->id);
  99  
 100          // Create users.
 101          $this->student = self::getDataGenerator()->create_user();
 102          $this->teacher = self::getDataGenerator()->create_user();
 103  
 104          // Users enrolments.
 105          $this->studentrole = $DB->get_record('role', array('shortname' => 'student'));
 106          $this->teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
 107          // Allow student to receive messages.
 108          $coursecontext = \context_course::instance($this->course->id);
 109          assign_capability('mod/quiz:emailnotifysubmission', CAP_ALLOW, $this->teacherrole->id, $coursecontext, true);
 110  
 111          $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
 112          $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherrole->id, 'manual');
 113      }
 114  
 115      /**
 116       * Create a quiz with questions including a started or finished attempt optionally
 117       *
 118       * @param  boolean $startattempt whether to start a new attempt
 119       * @param  boolean $finishattempt whether to finish the new attempt
 120       * @param  string $behaviour the quiz preferredbehaviour, defaults to 'deferredfeedback'.
 121       * @param  boolean $includeqattachments whether to include a question that supports attachments, defaults to false.
 122       * @param  array $extraoptions extra options for Quiz.
 123       * @return array array containing the quiz, context and the attempt
 124       */
 125      private function create_quiz_with_questions($startattempt = false, $finishattempt = false, $behaviour = 'deferredfeedback',
 126              $includeqattachments = false, $extraoptions = []) {
 127  
 128          // Create a new quiz with attempts.
 129          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
 130          $data = array('course' => $this->course->id,
 131                        'sumgrades' => 2,
 132                        'preferredbehaviour' => $behaviour);
 133          $data = array_merge($data, $extraoptions);
 134          $quiz = $quizgenerator->create_instance($data);
 135          $context = \context_module::instance($quiz->cmid);
 136  
 137          // Create a couple of questions.
 138          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 139  
 140          $cat = $questiongenerator->create_question_category();
 141          $question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
 142          quiz_add_quiz_question($question->id, $quiz);
 143          $question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
 144          quiz_add_quiz_question($question->id, $quiz);
 145  
 146          if ($includeqattachments) {
 147              $question = $questiongenerator->create_question('essay', null, array('category' => $cat->id, 'attachments' => 1,
 148                  'attachmentsrequired' => 1));
 149              quiz_add_quiz_question($question->id, $quiz);
 150          }
 151  
 152          $quizobj = quiz::create($quiz->id, $this->student->id);
 153  
 154          // Set grade to pass.
 155          $item = \grade_item::fetch(array('courseid' => $this->course->id, 'itemtype' => 'mod',
 156                                          'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
 157          $item->gradepass = 80;
 158          $item->update();
 159  
 160          if ($startattempt or $finishattempt) {
 161              // Now, do one attempt.
 162              $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
 163              $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
 164  
 165              $timenow = time();
 166              $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
 167              quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
 168              quiz_attempt_save_started($quizobj, $quba, $attempt);
 169              $attemptobj = quiz_attempt::create($attempt->id);
 170  
 171              if ($finishattempt) {
 172                  // Process some responses from the student.
 173                  $tosubmit = array(1 => array('answer' => '3.14'));
 174                  $attemptobj->process_submitted_actions(time(), false, $tosubmit);
 175  
 176                  // Finish the attempt.
 177                  $attemptobj->process_finish(time(), false);
 178              }
 179              return array($quiz, $context, $quizobj, $attempt, $attemptobj, $quba);
 180          } else {
 181              return array($quiz, $context, $quizobj);
 182          }
 183  
 184      }
 185  
 186      /*
 187       * Test get quizzes by courses
 188       */
 189      public function test_mod_quiz_get_quizzes_by_courses() {
 190          global $DB;
 191  
 192          // Create additional course.
 193          $course2 = self::getDataGenerator()->create_course();
 194  
 195          // Second quiz.
 196          $record = new \stdClass();
 197          $record->course = $course2->id;
 198          $record->intro = '<button>Test with HTML allowed.</button>';
 199          $quiz2 = self::getDataGenerator()->create_module('quiz', $record);
 200  
 201          // Execute real Moodle enrolment as we'll call unenrol() method on the instance later.
 202          $enrol = enrol_get_plugin('manual');
 203          $enrolinstances = enrol_get_instances($course2->id, true);
 204          foreach ($enrolinstances as $courseenrolinstance) {
 205              if ($courseenrolinstance->enrol == "manual") {
 206                  $instance2 = $courseenrolinstance;
 207                  break;
 208              }
 209          }
 210          $enrol->enrol_user($instance2, $this->student->id, $this->studentrole->id);
 211  
 212          self::setUser($this->student);
 213  
 214          $returndescription = mod_quiz_external::get_quizzes_by_courses_returns();
 215  
 216          // Create what we expect to be returned when querying the two courses.
 217          // First for the student user.
 218          $allusersfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'timeopen',
 219                                  'timeclose', 'grademethod', 'section', 'visible', 'groupmode', 'groupingid',
 220                                  'attempts', 'timelimit', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'sumgrades',
 221                                  'grade', 'preferredbehaviour', 'hasfeedback');
 222          $userswithaccessfields = array('attemptonlast', 'reviewattempt', 'reviewcorrectness', 'reviewmarks',
 223                                          'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer',
 224                                          'reviewoverallfeedback', 'questionsperpage', 'navmethod',
 225                                          'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks',
 226                                          'completionattemptsexhausted', 'completionpass', 'autosaveperiod', 'hasquestions',
 227                                          'overduehandling', 'graceperiod', 'canredoquestions', 'allowofflineattempts');
 228          $managerfields = array('shuffleanswers', 'timecreated', 'timemodified', 'password', 'subnet');
 229  
 230          // Add expected coursemodule and other data.
 231          $quiz1 = $this->quiz;
 232          $quiz1->coursemodule = $quiz1->cmid;
 233          $quiz1->introformat = 1;
 234          $quiz1->section = 0;
 235          $quiz1->visible = true;
 236          $quiz1->groupmode = 0;
 237          $quiz1->groupingid = 0;
 238          $quiz1->hasquestions = 0;
 239          $quiz1->hasfeedback = 0;
 240          $quiz1->completionpass = 0;
 241          $quiz1->autosaveperiod = get_config('quiz', 'autosaveperiod');
 242          $quiz1->introfiles = [];
 243  
 244          $quiz2->coursemodule = $quiz2->cmid;
 245          $quiz2->introformat = 1;
 246          $quiz2->section = 0;
 247          $quiz2->visible = true;
 248          $quiz2->groupmode = 0;
 249          $quiz2->groupingid = 0;
 250          $quiz2->hasquestions = 0;
 251          $quiz2->hasfeedback = 0;
 252          $quiz2->completionpass = 0;
 253          $quiz2->autosaveperiod = get_config('quiz', 'autosaveperiod');
 254          $quiz2->introfiles = [];
 255  
 256          foreach (array_merge($allusersfields, $userswithaccessfields) as $field) {
 257              $expected1[$field] = $quiz1->{$field};
 258              $expected2[$field] = $quiz2->{$field};
 259          }
 260  
 261          $expectedquizzes = array($expected2, $expected1);
 262  
 263          // Call the external function passing course ids.
 264          $result = mod_quiz_external::get_quizzes_by_courses(array($course2->id, $this->course->id));
 265          $result = \external_api::clean_returnvalue($returndescription, $result);
 266  
 267          $this->assertEquals($expectedquizzes, $result['quizzes']);
 268          $this->assertCount(0, $result['warnings']);
 269  
 270          // Call the external function without passing course id.
 271          $result = mod_quiz_external::get_quizzes_by_courses();
 272          $result = \external_api::clean_returnvalue($returndescription, $result);
 273          $this->assertEquals($expectedquizzes, $result['quizzes']);
 274          $this->assertCount(0, $result['warnings']);
 275  
 276          // Unenrol user from second course and alter expected quizzes.
 277          $enrol->unenrol_user($instance2, $this->student->id);
 278          array_shift($expectedquizzes);
 279  
 280          // Call the external function without passing course id.
 281          $result = mod_quiz_external::get_quizzes_by_courses();
 282          $result = \external_api::clean_returnvalue($returndescription, $result);
 283          $this->assertEquals($expectedquizzes, $result['quizzes']);
 284  
 285          // Call for the second course we unenrolled the user from, expected warning.
 286          $result = mod_quiz_external::get_quizzes_by_courses(array($course2->id));
 287          $this->assertCount(1, $result['warnings']);
 288          $this->assertEquals('1', $result['warnings'][0]['warningcode']);
 289          $this->assertEquals($course2->id, $result['warnings'][0]['itemid']);
 290  
 291          // Now, try as a teacher for getting all the additional fields.
 292          self::setUser($this->teacher);
 293  
 294          foreach ($managerfields as $field) {
 295              $expectedquizzes[0][$field] = $quiz1->{$field};
 296          }
 297  
 298          $result = mod_quiz_external::get_quizzes_by_courses();
 299          $result = \external_api::clean_returnvalue($returndescription, $result);
 300          $this->assertEquals($expectedquizzes, $result['quizzes']);
 301  
 302          // Admin also should get all the information.
 303          self::setAdminUser();
 304  
 305          $result = mod_quiz_external::get_quizzes_by_courses(array($this->course->id));
 306          $result = \external_api::clean_returnvalue($returndescription, $result);
 307          $this->assertEquals($expectedquizzes, $result['quizzes']);
 308  
 309          // Now, prevent access.
 310          $enrol->enrol_user($instance2, $this->student->id);
 311  
 312          self::setUser($this->student);
 313  
 314          $quiz2->timeclose = time() - DAYSECS;
 315          $DB->update_record('quiz', $quiz2);
 316  
 317          $result = mod_quiz_external::get_quizzes_by_courses();
 318          $result = \external_api::clean_returnvalue($returndescription, $result);
 319          $this->assertCount(2, $result['quizzes']);
 320          // We only see a limited set of fields.
 321          $this->assertCount(4, $result['quizzes'][0]);
 322          $this->assertEquals($quiz2->id, $result['quizzes'][0]['id']);
 323          $this->assertEquals($quiz2->cmid, $result['quizzes'][0]['coursemodule']);
 324          $this->assertEquals($quiz2->course, $result['quizzes'][0]['course']);
 325          $this->assertEquals($quiz2->name, $result['quizzes'][0]['name']);
 326          $this->assertEquals($quiz2->course, $result['quizzes'][0]['course']);
 327  
 328          $this->assertFalse(isset($result['quizzes'][0]['timelimit']));
 329  
 330      }
 331  
 332      /**
 333       * Test test_view_quiz
 334       */
 335      public function test_view_quiz() {
 336          global $DB;
 337  
 338          // Test invalid instance id.
 339          try {
 340              mod_quiz_external::view_quiz(0);
 341              $this->fail('Exception expected due to invalid mod_quiz instance id.');
 342          } catch (\moodle_exception $e) {
 343              $this->assertEquals('invalidrecord', $e->errorcode);
 344          }
 345  
 346          // Test not-enrolled user.
 347          $usernotenrolled = self::getDataGenerator()->create_user();
 348          $this->setUser($usernotenrolled);
 349          try {
 350              mod_quiz_external::view_quiz($this->quiz->id);
 351              $this->fail('Exception expected due to not enrolled user.');
 352          } catch (\moodle_exception $e) {
 353              $this->assertEquals('requireloginerror', $e->errorcode);
 354          }
 355  
 356          // Test user with full capabilities.
 357          $this->setUser($this->student);
 358  
 359          // Trigger and capture the event.
 360          $sink = $this->redirectEvents();
 361  
 362          $result = mod_quiz_external::view_quiz($this->quiz->id);
 363          $result = \external_api::clean_returnvalue(mod_quiz_external::view_quiz_returns(), $result);
 364          $this->assertTrue($result['status']);
 365  
 366          $events = $sink->get_events();
 367          $this->assertCount(1, $events);
 368          $event = array_shift($events);
 369  
 370          // Checking that the event contains the expected values.
 371          $this->assertInstanceOf('\mod_quiz\event\course_module_viewed', $event);
 372          $this->assertEquals($this->context, $event->get_context());
 373          $moodlequiz = new \moodle_url('/mod/quiz/view.php', array('id' => $this->cm->id));
 374          $this->assertEquals($moodlequiz, $event->get_url());
 375          $this->assertEventContextNotUsed($event);
 376          $this->assertNotEmpty($event->get_name());
 377  
 378          // Test user with no capabilities.
 379          // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
 380          assign_capability('mod/quiz:view', CAP_PROHIBIT, $this->studentrole->id, $this->context->id);
 381          // Empty all the caches that may be affected  by this change.
 382          accesslib_clear_all_caches_for_unit_testing();
 383          \course_modinfo::clear_instance_cache();
 384  
 385          try {
 386              mod_quiz_external::view_quiz($this->quiz->id);
 387              $this->fail('Exception expected due to missing capability.');
 388          } catch (\moodle_exception $e) {
 389              $this->assertEquals('requireloginerror', $e->errorcode);
 390          }
 391  
 392      }
 393  
 394      /**
 395       * Test get_user_attempts
 396       */
 397      public function test_get_user_attempts() {
 398  
 399          // Create a quiz with one attempt finished.
 400          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true, true);
 401  
 402          $this->setUser($this->student);
 403          $result = mod_quiz_external::get_user_attempts($quiz->id);
 404          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 405  
 406          $this->assertCount(1, $result['attempts']);
 407          $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
 408          $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']);
 409          $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
 410          $this->assertEquals(1, $result['attempts'][0]['attempt']);
 411          $this->assertArrayHasKey('sumgrades', $result['attempts'][0]);
 412          $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']);
 413  
 414          // Test filters. Only finished.
 415          $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'finished', false);
 416          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 417  
 418          $this->assertCount(1, $result['attempts']);
 419          $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
 420  
 421          // Test filters. All attempts.
 422          $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false);
 423          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 424  
 425          $this->assertCount(1, $result['attempts']);
 426          $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
 427  
 428          // Test filters. Unfinished.
 429          $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false);
 430          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 431  
 432          $this->assertCount(0, $result['attempts']);
 433  
 434          // Start a new attempt, but not finish it.
 435          $timenow = time();
 436          $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
 437          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
 438          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
 439  
 440          quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
 441          quiz_attempt_save_started($quizobj, $quba, $attempt);
 442  
 443          // Test filters. All attempts.
 444          $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false);
 445          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 446  
 447          $this->assertCount(2, $result['attempts']);
 448  
 449          // Test filters. Unfinished.
 450          $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false);
 451          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 452  
 453          $this->assertCount(1, $result['attempts']);
 454  
 455          // Test manager can see user attempts.
 456          $this->setUser($this->teacher);
 457          $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id);
 458          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 459  
 460          $this->assertCount(1, $result['attempts']);
 461          $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
 462  
 463          $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all');
 464          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 465  
 466          $this->assertCount(2, $result['attempts']);
 467          $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
 468  
 469          // Invalid parameters.
 470          try {
 471              mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER');
 472              $this->fail('Exception expected due to missing capability.');
 473          } catch (\invalid_parameter_exception $e) {
 474              $this->assertEquals('invalidparameter', $e->errorcode);
 475          }
 476      }
 477  
 478      /**
 479       * Test get_user_attempts with marks hidden
 480       */
 481      public function test_get_user_attempts_with_marks_hidden() {
 482          // Create quiz with one attempt finished and hide the mark.
 483          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(
 484                  true, true, 'deferredfeedback', false,
 485                  ['marksduring' => 0, 'marksimmediately' => 0, 'marksopen' => 0, 'marksclosed' => 0]);
 486  
 487          // Student cannot see the grades.
 488          $this->setUser($this->student);
 489          $result = mod_quiz_external::get_user_attempts($quiz->id);
 490          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 491  
 492          $this->assertCount(1, $result['attempts']);
 493          $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
 494          $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']);
 495          $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
 496          $this->assertEquals(1, $result['attempts'][0]['attempt']);
 497          $this->assertArrayHasKey('sumgrades', $result['attempts'][0]);
 498          $this->assertEquals(null, $result['attempts'][0]['sumgrades']);
 499  
 500          // Test manager can see user grades.
 501          $this->setUser($this->teacher);
 502          $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id);
 503          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
 504  
 505          $this->assertCount(1, $result['attempts']);
 506          $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
 507          $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']);
 508          $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
 509          $this->assertEquals(1, $result['attempts'][0]['attempt']);
 510          $this->assertArrayHasKey('sumgrades', $result['attempts'][0]);
 511          $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']);
 512      }
 513  
 514      /**
 515       * Test get_user_best_grade
 516       */
 517      public function test_get_user_best_grade() {
 518          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
 519          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 520          $questioncat = $questiongenerator->create_question_category();
 521  
 522          // Create a new quiz.
 523          $quizapi1 = $quizgenerator->create_instance([
 524                  'name' => 'Test Quiz API 1',
 525                  'course' => $this->course->id,
 526                  'sumgrades' => 1
 527          ]);
 528          $quizapi2 = $quizgenerator->create_instance([
 529                  'name' => 'Test Quiz API 2',
 530                  'course' => $this->course->id,
 531                  'sumgrades' => 1,
 532                  'marksduring' => 0,
 533                  'marksimmediately' => 0,
 534                  'marksopen' => 0,
 535                  'marksclosed' => 0
 536          ]);
 537  
 538          // Create a question.
 539          $question = $questiongenerator->create_question('numerical', null, ['category' => $questioncat->id]);
 540  
 541          // Add question to the quizzes.
 542          quiz_add_quiz_question($question->id, $quizapi1);
 543          quiz_add_quiz_question($question->id, $quizapi2);
 544  
 545          // Create quiz object.
 546          $quizapiobj1 = quiz::create($quizapi1->id, $this->student->id);
 547          $quizapiobj2 = quiz::create($quizapi2->id, $this->student->id);
 548  
 549          // Set grade to pass.
 550          $item = \grade_item::fetch([
 551                  'courseid' => $this->course->id,
 552                  'itemtype' => 'mod',
 553                  'itemmodule' => 'quiz',
 554                  'iteminstance' => $quizapi1->id,
 555                  'outcomeid' => null
 556          ]);
 557          $item->gradepass = 80;
 558          $item->update();
 559  
 560          $item = \grade_item::fetch([
 561                  'courseid' => $this->course->id,
 562                  'itemtype' => 'mod',
 563                  'itemmodule' => 'quiz',
 564                  'iteminstance' => $quizapi2->id,
 565                  'outcomeid' => null
 566          ]);
 567          $item->gradepass = 80;
 568          $item->update();
 569  
 570          // Start the passing attempt.
 571          $quba1 = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizapiobj1->get_context());
 572          $quba1->set_preferred_behaviour($quizapiobj1->get_quiz()->preferredbehaviour);
 573  
 574          $quba2 = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizapiobj2->get_context());
 575          $quba2->set_preferred_behaviour($quizapiobj2->get_quiz()->preferredbehaviour);
 576  
 577          // Start the testing for quizapi1 that allow the student to view the grade.
 578  
 579          $this->setUser($this->student);
 580          $result = mod_quiz_external::get_user_best_grade($quizapi1->id);
 581          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
 582  
 583          // No grades yet.
 584          $this->assertFalse($result['hasgrade']);
 585          $this->assertTrue(!isset($result['grade']));
 586  
 587          // Start the attempt.
 588          $timenow = time();
 589          $attempt = quiz_create_attempt($quizapiobj1, 1, false, $timenow, false, $this->student->id);
 590          quiz_start_new_attempt($quizapiobj1, $quba1, $attempt, 1, $timenow);
 591          quiz_attempt_save_started($quizapiobj1, $quba1, $attempt);
 592  
 593          // Process some responses from the student.
 594          $attemptobj = quiz_attempt::create($attempt->id);
 595          $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]);
 596  
 597          // Finish the attempt.
 598          $attemptobj->process_finish($timenow, false);
 599  
 600          $result = mod_quiz_external::get_user_best_grade($quizapi1->id);
 601          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
 602  
 603          // Now I have grades.
 604          $this->assertTrue($result['hasgrade']);
 605          $this->assertEquals(100.0, $result['grade']);
 606          $this->assertEquals(80, $result['gradetopass']);
 607  
 608          // We should not see other users grades.
 609          $anotherstudent = self::getDataGenerator()->create_user();
 610          $this->getDataGenerator()->enrol_user($anotherstudent->id, $this->course->id, $this->studentrole->id, 'manual');
 611  
 612          try {
 613              mod_quiz_external::get_user_best_grade($quizapi1->id, $anotherstudent->id);
 614              $this->fail('Exception expected due to missing capability.');
 615          } catch (\required_capability_exception $e) {
 616              $this->assertEquals('nopermissions', $e->errorcode);
 617          }
 618  
 619          // Teacher must be able to see student grades.
 620          $this->setUser($this->teacher);
 621  
 622          $result = mod_quiz_external::get_user_best_grade($quizapi1->id, $this->student->id);
 623          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
 624  
 625          $this->assertTrue($result['hasgrade']);
 626          $this->assertEquals(100.0, $result['grade']);
 627          $this->assertEquals(80, $result['gradetopass']);
 628  
 629          // Invalid user.
 630          try {
 631              mod_quiz_external::get_user_best_grade($this->quiz->id, -1);
 632              $this->fail('Exception expected due to missing capability.');
 633          } catch (\dml_missing_record_exception $e) {
 634              $this->assertEquals('invaliduser', $e->errorcode);
 635          }
 636  
 637          // End the testing for quizapi1 that allow the student to view the grade.
 638  
 639          // Start the testing for quizapi2 that do not allow the student to view the grade.
 640  
 641          $this->setUser($this->student);
 642          $result = mod_quiz_external::get_user_best_grade($quizapi2->id);
 643          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
 644  
 645          // No grades yet.
 646          $this->assertFalse($result['hasgrade']);
 647          $this->assertTrue(!isset($result['grade']));
 648  
 649          // Start the attempt.
 650          $timenow = time();
 651          $attempt = quiz_create_attempt($quizapiobj2, 1, false, $timenow, false, $this->student->id);
 652          quiz_start_new_attempt($quizapiobj2, $quba2, $attempt, 1, $timenow);
 653          quiz_attempt_save_started($quizapiobj2, $quba2, $attempt);
 654  
 655          // Process some responses from the student.
 656          $attemptobj = quiz_attempt::create($attempt->id);
 657          $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]);
 658  
 659          // Finish the attempt.
 660          $attemptobj->process_finish($timenow, false);
 661  
 662          $result = mod_quiz_external::get_user_best_grade($quizapi2->id);
 663          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
 664  
 665          // Now I have grades but I will not be allowed to see it.
 666          $this->assertFalse($result['hasgrade']);
 667          $this->assertTrue(!isset($result['grade']));
 668  
 669          // Teacher must be able to see student grades.
 670          $this->setUser($this->teacher);
 671  
 672          $result = mod_quiz_external::get_user_best_grade($quizapi2->id, $this->student->id);
 673          $result = \external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
 674  
 675          $this->assertTrue($result['hasgrade']);
 676          $this->assertEquals(100.0, $result['grade']);
 677  
 678          // End the testing for quizapi2 that do not allow the student to view the grade.
 679  
 680      }
 681      /**
 682       * Test get_combined_review_options.
 683       * This is a basic test, this is already tested in mod_quiz_display_options_testcase.
 684       */
 685      public function test_get_combined_review_options() {
 686          global $DB;
 687  
 688          // Create a new quiz with attempts.
 689          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
 690          $data = array('course' => $this->course->id,
 691                        'sumgrades' => 1);
 692          $quiz = $quizgenerator->create_instance($data);
 693  
 694          // Create a couple of questions.
 695          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 696  
 697          $cat = $questiongenerator->create_question_category();
 698          $question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
 699          quiz_add_quiz_question($question->id, $quiz);
 700  
 701          $quizobj = quiz::create($quiz->id, $this->student->id);
 702  
 703          // Set grade to pass.
 704          $item = \grade_item::fetch(array('courseid' => $this->course->id, 'itemtype' => 'mod',
 705                                          'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
 706          $item->gradepass = 80;
 707          $item->update();
 708  
 709          // Start the passing attempt.
 710          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
 711          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
 712  
 713          $timenow = time();
 714          $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
 715          quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
 716          quiz_attempt_save_started($quizobj, $quba, $attempt);
 717  
 718          $this->setUser($this->student);
 719  
 720          $result = mod_quiz_external::get_combined_review_options($quiz->id);
 721          $result = \external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
 722  
 723          // Expected values.
 724          $expected = array(
 725              "someoptions" => array(
 726                  array("name" => "feedback", "value" => 1),
 727                  array("name" => "generalfeedback", "value" => 1),
 728                  array("name" => "rightanswer", "value" => 1),
 729                  array("name" => "overallfeedback", "value" => 0),
 730                  array("name" => "marks", "value" => 2),
 731              ),
 732              "alloptions" => array(
 733                  array("name" => "feedback", "value" => 1),
 734                  array("name" => "generalfeedback", "value" => 1),
 735                  array("name" => "rightanswer", "value" => 1),
 736                  array("name" => "overallfeedback", "value" => 0),
 737                  array("name" => "marks", "value" => 2),
 738              ),
 739              "warnings" => [],
 740          );
 741  
 742          $this->assertEquals($expected, $result);
 743  
 744          // Now, finish the attempt.
 745          $attemptobj = quiz_attempt::create($attempt->id);
 746          $attemptobj->process_finish($timenow, false);
 747  
 748          $expected = array(
 749              "someoptions" => array(
 750                  array("name" => "feedback", "value" => 1),
 751                  array("name" => "generalfeedback", "value" => 1),
 752                  array("name" => "rightanswer", "value" => 1),
 753                  array("name" => "overallfeedback", "value" => 1),
 754                  array("name" => "marks", "value" => 2),
 755              ),
 756              "alloptions" => array(
 757                  array("name" => "feedback", "value" => 1),
 758                  array("name" => "generalfeedback", "value" => 1),
 759                  array("name" => "rightanswer", "value" => 1),
 760                  array("name" => "overallfeedback", "value" => 1),
 761                  array("name" => "marks", "value" => 2),
 762              ),
 763              "warnings" => [],
 764          );
 765  
 766          // We should see now the overall feedback.
 767          $result = mod_quiz_external::get_combined_review_options($quiz->id);
 768          $result = \external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
 769          $this->assertEquals($expected, $result);
 770  
 771          // Start a new attempt, but not finish it.
 772          $timenow = time();
 773          $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
 774          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
 775          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
 776          quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
 777          quiz_attempt_save_started($quizobj, $quba, $attempt);
 778  
 779          $expected = array(
 780              "someoptions" => array(
 781                  array("name" => "feedback", "value" => 1),
 782                  array("name" => "generalfeedback", "value" => 1),
 783                  array("name" => "rightanswer", "value" => 1),
 784                  array("name" => "overallfeedback", "value" => 1),
 785                  array("name" => "marks", "value" => 2),
 786              ),
 787              "alloptions" => array(
 788                  array("name" => "feedback", "value" => 1),
 789                  array("name" => "generalfeedback", "value" => 1),
 790                  array("name" => "rightanswer", "value" => 1),
 791                  array("name" => "overallfeedback", "value" => 0),
 792                  array("name" => "marks", "value" => 2),
 793              ),
 794              "warnings" => [],
 795          );
 796  
 797          $result = mod_quiz_external::get_combined_review_options($quiz->id);
 798          $result = \external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
 799          $this->assertEquals($expected, $result);
 800  
 801          // Teacher, for see student options.
 802          $this->setUser($this->teacher);
 803  
 804          $result = mod_quiz_external::get_combined_review_options($quiz->id, $this->student->id);
 805          $result = \external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
 806  
 807          $this->assertEquals($expected, $result);
 808  
 809          // Invalid user.
 810          try {
 811              mod_quiz_external::get_combined_review_options($quiz->id, -1);
 812              $this->fail('Exception expected due to missing capability.');
 813          } catch (\dml_missing_record_exception $e) {
 814              $this->assertEquals('invaliduser', $e->errorcode);
 815          }
 816      }
 817  
 818      /**
 819       * Test start_attempt
 820       */
 821      public function test_start_attempt() {
 822          global $DB;
 823  
 824          // Create a new quiz with questions.
 825          list($quiz, $context, $quizobj) = $this->create_quiz_with_questions();
 826  
 827          $this->setUser($this->student);
 828  
 829          // Try to open attempt in closed quiz.
 830          $quiz->timeopen = time() - WEEKSECS;
 831          $quiz->timeclose = time() - DAYSECS;
 832          $DB->update_record('quiz', $quiz);
 833          $result = mod_quiz_external::start_attempt($quiz->id);
 834          $result = \external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result);
 835  
 836          $this->assertEquals([], $result['attempt']);
 837          $this->assertCount(1, $result['warnings']);
 838  
 839          // Now with a password.
 840          $quiz->timeopen = 0;
 841          $quiz->timeclose = 0;
 842          $quiz->password = 'abc';
 843          $DB->update_record('quiz', $quiz);
 844  
 845          try {
 846              mod_quiz_external::start_attempt($quiz->id, array(array("name" => "quizpassword", "value" => 'bad')));
 847              $this->fail('Exception expected due to invalid passwod.');
 848          } catch (\moodle_exception $e) {
 849              $this->assertEquals(get_string('passworderror', 'quizaccess_password'), $e->errorcode);
 850          }
 851  
 852          // Now, try everything correct.
 853          $result = mod_quiz_external::start_attempt($quiz->id, array(array("name" => "quizpassword", "value" => 'abc')));
 854          $result = \external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result);
 855  
 856          $this->assertEquals(1, $result['attempt']['attempt']);
 857          $this->assertEquals($this->student->id, $result['attempt']['userid']);
 858          $this->assertEquals($quiz->id, $result['attempt']['quiz']);
 859          $this->assertCount(0, $result['warnings']);
 860          $attemptid = $result['attempt']['id'];
 861  
 862          // We are good, try to start a new attempt now.
 863  
 864          try {
 865              mod_quiz_external::start_attempt($quiz->id, array(array("name" => "quizpassword", "value" => 'abc')));
 866              $this->fail('Exception expected due to attempt not finished.');
 867          } catch (\moodle_quiz_exception $e) {
 868              $this->assertEquals('attemptstillinprogress', $e->errorcode);
 869          }
 870  
 871          // Finish the started attempt.
 872  
 873          // Process some responses from the student.
 874          $timenow = time();
 875          $attemptobj = quiz_attempt::create($attemptid);
 876          $tosubmit = array(1 => array('answer' => '3.14'));
 877          $attemptobj->process_submitted_actions($timenow, false, $tosubmit);
 878  
 879          // Finish the attempt.
 880          $attemptobj = quiz_attempt::create($attemptid);
 881          $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question());
 882          $attemptobj->process_finish($timenow, false);
 883  
 884          // We should be able to start a new attempt.
 885          $result = mod_quiz_external::start_attempt($quiz->id, array(array("name" => "quizpassword", "value" => 'abc')));
 886          $result = \external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result);
 887  
 888          $this->assertEquals(2, $result['attempt']['attempt']);
 889          $this->assertEquals($this->student->id, $result['attempt']['userid']);
 890          $this->assertEquals($quiz->id, $result['attempt']['quiz']);
 891          $this->assertCount(0, $result['warnings']);
 892  
 893          // Test user with no capabilities.
 894          // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
 895          assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $this->studentrole->id, $context->id);
 896          // Empty all the caches that may be affected  by this change.
 897          accesslib_clear_all_caches_for_unit_testing();
 898          \course_modinfo::clear_instance_cache();
 899  
 900          try {
 901              mod_quiz_external::start_attempt($quiz->id);
 902              $this->fail('Exception expected due to missing capability.');
 903          } catch (\required_capability_exception $e) {
 904              $this->assertEquals('nopermissions', $e->errorcode);
 905          }
 906  
 907      }
 908  
 909      /**
 910       * Test validate_attempt
 911       */
 912      public function test_validate_attempt() {
 913          global $DB;
 914  
 915          // Create a new quiz with one attempt started.
 916          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
 917  
 918          $this->setUser($this->student);
 919  
 920          // Invalid attempt.
 921          try {
 922              $params = array('attemptid' => -1, 'page' => 0);
 923              testable_mod_quiz_external::validate_attempt($params);
 924              $this->fail('Exception expected due to invalid attempt id.');
 925          } catch (\dml_missing_record_exception $e) {
 926              $this->assertEquals('invalidrecord', $e->errorcode);
 927          }
 928  
 929          // Test OK case.
 930          $params = array('attemptid' => $attempt->id, 'page' => 0);
 931          $result = testable_mod_quiz_external::validate_attempt($params);
 932          $this->assertEquals($attempt->id, $result[0]->get_attempt()->id);
 933          $this->assertEquals([], $result[1]);
 934  
 935          // Test with preflight data.
 936          $quiz->password = 'abc';
 937          $DB->update_record('quiz', $quiz);
 938  
 939          try {
 940              $params = array('attemptid' => $attempt->id, 'page' => 0,
 941                              'preflightdata' => array(array("name" => "quizpassword", "value" => 'bad')));
 942              testable_mod_quiz_external::validate_attempt($params);
 943              $this->fail('Exception expected due to invalid passwod.');
 944          } catch (\moodle_exception $e) {
 945              $this->assertEquals(get_string('passworderror', 'quizaccess_password'), $e->errorcode);
 946          }
 947  
 948          // Now, try everything correct.
 949          $params['preflightdata'][0]['value'] = 'abc';
 950          $result = testable_mod_quiz_external::validate_attempt($params);
 951          $this->assertEquals($attempt->id, $result[0]->get_attempt()->id);
 952          $this->assertEquals([], $result[1]);
 953  
 954          // Page out of range.
 955          $DB->update_record('quiz', $quiz);
 956          $params['page'] = 4;
 957          try {
 958              testable_mod_quiz_external::validate_attempt($params);
 959              $this->fail('Exception expected due to page out of range.');
 960          } catch (\moodle_quiz_exception $e) {
 961              $this->assertEquals('Invalid page number', $e->errorcode);
 962          }
 963  
 964          $params['page'] = 0;
 965          // Try to open attempt in closed quiz.
 966          $quiz->timeopen = time() - WEEKSECS;
 967          $quiz->timeclose = time() - DAYSECS;
 968          $DB->update_record('quiz', $quiz);
 969  
 970          // This should work, ommit access rules.
 971          testable_mod_quiz_external::validate_attempt($params, false);
 972  
 973          // Get a generic error because prior to checking the dates the attempt is closed.
 974          try {
 975              testable_mod_quiz_external::validate_attempt($params);
 976              $this->fail('Exception expected due to passed dates.');
 977          } catch (\moodle_quiz_exception $e) {
 978              $this->assertEquals('attempterror', $e->errorcode);
 979          }
 980  
 981          // Finish the attempt.
 982          $attemptobj = quiz_attempt::create($attempt->id);
 983          $attemptobj->process_finish(time(), false);
 984  
 985          try {
 986              testable_mod_quiz_external::validate_attempt($params, false);
 987              $this->fail('Exception expected due to attempt finished.');
 988          } catch (\moodle_quiz_exception $e) {
 989              $this->assertEquals('attemptalreadyclosed', $e->errorcode);
 990          }
 991  
 992          // Test user with no capabilities.
 993          // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
 994          assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $this->studentrole->id, $context->id);
 995          // Empty all the caches that may be affected  by this change.
 996          accesslib_clear_all_caches_for_unit_testing();
 997          \course_modinfo::clear_instance_cache();
 998  
 999          try {
1000              testable_mod_quiz_external::validate_attempt($params);
1001              $this->fail('Exception expected due to missing permissions.');
1002          } catch (\required_capability_exception $e) {
1003              $this->assertEquals('nopermissions', $e->errorcode);
1004          }
1005  
1006          // Now try with a different user.
1007          $this->setUser($this->teacher);
1008  
1009          $params['page'] = 0;
1010          try {
1011              testable_mod_quiz_external::validate_attempt($params);
1012              $this->fail('Exception expected due to not your attempt.');
1013          } catch (\moodle_quiz_exception $e) {
1014              $this->assertEquals('notyourattempt', $e->errorcode);
1015          }
1016      }
1017  
1018      /**
1019       * Test get_attempt_data
1020       */
1021      public function test_get_attempt_data() {
1022          global $DB;
1023  
1024          $timenow = time();
1025          // Create a new quiz with one attempt started.
1026          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
1027  
1028          // Set correctness mask so questions state can be fetched only after finishing the attempt.
1029          $DB->set_field('quiz', 'reviewcorrectness', mod_quiz_display_options::IMMEDIATELY_AFTER, array('id' => $quiz->id));
1030  
1031          $quizobj = $attemptobj->get_quizobj();
1032          $quizobj->preload_questions();
1033          $quizobj->load_questions();
1034          $questions = $quizobj->get_questions();
1035  
1036          $this->setUser($this->student);
1037  
1038          // We receive one question per page.
1039          $result = mod_quiz_external::get_attempt_data($attempt->id, 0);
1040          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1041  
1042          $this->assertEquals($attempt, (object) $result['attempt']);
1043          $this->assertEquals(1, $result['nextpage']);
1044          $this->assertCount(0, $result['messages']);
1045          $this->assertCount(1, $result['questions']);
1046          $this->assertEquals(1, $result['questions'][0]['slot']);
1047          $this->assertEquals(1, $result['questions'][0]['number']);
1048          $this->assertEquals('numerical', $result['questions'][0]['type']);
1049          $this->assertArrayNotHasKey('state', $result['questions'][0]);  // We don't receive the state yet.
1050          $this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']);
1051          $this->assertFalse($result['questions'][0]['flagged']);
1052          $this->assertEquals(0, $result['questions'][0]['page']);
1053          $this->assertEmpty($result['questions'][0]['mark']);
1054          $this->assertEquals(1, $result['questions'][0]['maxmark']);
1055          $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1056          $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1057          $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1058  
1059          // Now try the last page.
1060          $result = mod_quiz_external::get_attempt_data($attempt->id, 1);
1061          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1062  
1063          $this->assertEquals($attempt, (object) $result['attempt']);
1064          $this->assertEquals(-1, $result['nextpage']);
1065          $this->assertCount(0, $result['messages']);
1066          $this->assertCount(1, $result['questions']);
1067          $this->assertEquals(2, $result['questions'][0]['slot']);
1068          $this->assertEquals(2, $result['questions'][0]['number']);
1069          $this->assertEquals('numerical', $result['questions'][0]['type']);
1070          $this->assertArrayNotHasKey('state', $result['questions'][0]);  // We don't receive the state yet.
1071          $this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']);
1072          $this->assertFalse($result['questions'][0]['flagged']);
1073          $this->assertEquals(1, $result['questions'][0]['page']);
1074          $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1075          $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1076          $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1077  
1078          // Finish previous attempt.
1079          $attemptobj->process_finish(time(), false);
1080  
1081          // Now we should receive the question state.
1082          $result = mod_quiz_external::get_attempt_review($attempt->id, 1);
1083          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result);
1084          $this->assertEquals('gaveup', $result['questions'][0]['state']);
1085  
1086          // Change setting and expect two pages.
1087          $quiz->questionsperpage = 4;
1088          $DB->update_record('quiz', $quiz);
1089          quiz_repaginate_questions($quiz->id, $quiz->questionsperpage);
1090  
1091          // Start with new attempt with the new layout.
1092          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1093          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1094  
1095          $timenow = time();
1096          $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
1097          quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
1098          quiz_attempt_save_started($quizobj, $quba, $attempt);
1099  
1100          // We receive two questions per page.
1101          $result = mod_quiz_external::get_attempt_data($attempt->id, 0);
1102          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1103          $this->assertCount(2, $result['questions']);
1104          $this->assertEquals(-1, $result['nextpage']);
1105  
1106          // Check questions looks good.
1107          $found = 0;
1108          foreach ($questions as $question) {
1109              foreach ($result['questions'] as $rquestion) {
1110                  if ($rquestion['slot'] == $question->slot) {
1111                      $this->assertTrue(strpos($rquestion['html'], "qid=$question->id") !== false);
1112                      $found++;
1113                  }
1114              }
1115          }
1116          $this->assertEquals(2, $found);
1117  
1118      }
1119  
1120      /**
1121       * Test get_attempt_data with blocked questions.
1122       * @since 3.2
1123       */
1124      public function test_get_attempt_data_with_blocked_questions() {
1125          global $DB;
1126  
1127          // Create a new quiz with one attempt started and using immediatefeedback.
1128          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(
1129                  true, false, 'immediatefeedback');
1130  
1131          $quizobj = $attemptobj->get_quizobj();
1132  
1133          // Make second question blocked by the first one.
1134          $structure = $quizobj->get_structure();
1135          $slots = $structure->get_slots();
1136          $structure->update_question_dependency(end($slots)->id, true);
1137  
1138          $quizobj->preload_questions();
1139          $quizobj->load_questions();
1140          $questions = $quizobj->get_questions();
1141  
1142          $this->setUser($this->student);
1143  
1144          // We receive one question per page.
1145          $result = mod_quiz_external::get_attempt_data($attempt->id, 0);
1146          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1147  
1148          $this->assertEquals($attempt, (object) $result['attempt']);
1149          $this->assertCount(1, $result['questions']);
1150          $this->assertEquals(1, $result['questions'][0]['slot']);
1151          $this->assertEquals(1, $result['questions'][0]['number']);
1152          $this->assertEquals(false, $result['questions'][0]['blockedbyprevious']);
1153  
1154          // Now try the last page.
1155          $result = mod_quiz_external::get_attempt_data($attempt->id, 1);
1156          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1157  
1158          $this->assertEquals($attempt, (object) $result['attempt']);
1159          $this->assertCount(1, $result['questions']);
1160          $this->assertEquals(2, $result['questions'][0]['slot']);
1161          $this->assertEquals(2, $result['questions'][0]['number']);
1162          $this->assertEquals(true, $result['questions'][0]['blockedbyprevious']);
1163      }
1164  
1165      /**
1166       * Test get_attempt_summary
1167       */
1168      public function test_get_attempt_summary() {
1169  
1170          $timenow = time();
1171          // Create a new quiz with one attempt started.
1172          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
1173  
1174          $this->setUser($this->student);
1175          $result = mod_quiz_external::get_attempt_summary($attempt->id);
1176          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1177  
1178          // Check the state, flagged and mark data is correct.
1179          $this->assertEquals('todo', $result['questions'][0]['state']);
1180          $this->assertEquals('todo', $result['questions'][1]['state']);
1181          $this->assertEquals(1, $result['questions'][0]['number']);
1182          $this->assertEquals(2, $result['questions'][1]['number']);
1183          $this->assertFalse($result['questions'][0]['flagged']);
1184          $this->assertFalse($result['questions'][1]['flagged']);
1185          $this->assertEmpty($result['questions'][0]['mark']);
1186          $this->assertEmpty($result['questions'][1]['mark']);
1187          $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1188          $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1189          $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1190          $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']);
1191          $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1192          $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']);
1193  
1194          // Check question options.
1195          $this->assertNotEmpty(5, $result['questions'][0]['settings']);
1196          // Check at least some settings returned.
1197          $this->assertCount(4, (array) json_decode($result['questions'][0]['settings']));
1198  
1199          // Submit a response for the first question.
1200          $tosubmit = array(1 => array('answer' => '3.14'));
1201          $attemptobj->process_submitted_actions(time(), false, $tosubmit);
1202          $result = mod_quiz_external::get_attempt_summary($attempt->id);
1203          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1204  
1205          // Check it's marked as completed only the first one.
1206          $this->assertEquals('complete', $result['questions'][0]['state']);
1207          $this->assertEquals('todo', $result['questions'][1]['state']);
1208          $this->assertEquals(1, $result['questions'][0]['number']);
1209          $this->assertEquals(2, $result['questions'][1]['number']);
1210          $this->assertFalse($result['questions'][0]['flagged']);
1211          $this->assertFalse($result['questions'][1]['flagged']);
1212          $this->assertEmpty($result['questions'][0]['mark']);
1213          $this->assertEmpty($result['questions'][1]['mark']);
1214          $this->assertEquals(2, $result['questions'][0]['sequencecheck']);
1215          $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1216          $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1217          $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']);
1218          $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1219          $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']);
1220  
1221      }
1222  
1223      /**
1224       * Test save_attempt
1225       */
1226      public function test_save_attempt() {
1227  
1228          $timenow = time();
1229          // Create a new quiz with one attempt started.
1230          list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true);
1231  
1232          // Response for slot 1.
1233          $prefix = $quba->get_field_prefix(1);
1234          $data = array(
1235              array('name' => 'slots', 'value' => 1),
1236              array('name' => $prefix . ':sequencecheck',
1237                      'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()),
1238              array('name' => $prefix . 'answer', 'value' => 1),
1239          );
1240  
1241          $this->setUser($this->student);
1242  
1243          $result = mod_quiz_external::save_attempt($attempt->id, $data);
1244          $result = \external_api::clean_returnvalue(mod_quiz_external::save_attempt_returns(), $result);
1245          $this->assertTrue($result['status']);
1246  
1247          // Now, get the summary.
1248          $result = mod_quiz_external::get_attempt_summary($attempt->id);
1249          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1250  
1251          // Check it's marked as completed only the first one.
1252          $this->assertEquals('complete', $result['questions'][0]['state']);
1253          $this->assertEquals('todo', $result['questions'][1]['state']);
1254          $this->assertEquals(1, $result['questions'][0]['number']);
1255          $this->assertEquals(2, $result['questions'][1]['number']);
1256          $this->assertFalse($result['questions'][0]['flagged']);
1257          $this->assertFalse($result['questions'][1]['flagged']);
1258          $this->assertEmpty($result['questions'][0]['mark']);
1259          $this->assertEmpty($result['questions'][1]['mark']);
1260          $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1261          $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1262          $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1263          $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']);
1264          $this->assertEquals(true, $result['questions'][0]['hasautosavedstep']);
1265          $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']);
1266  
1267          // Now, second slot.
1268          $prefix = $quba->get_field_prefix(2);
1269          $data = array(
1270              array('name' => 'slots', 'value' => 2),
1271              array('name' => $prefix . ':sequencecheck',
1272                      'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()),
1273              array('name' => $prefix . 'answer', 'value' => 1),
1274          );
1275  
1276          $result = mod_quiz_external::save_attempt($attempt->id, $data);
1277          $result = \external_api::clean_returnvalue(mod_quiz_external::save_attempt_returns(), $result);
1278          $this->assertTrue($result['status']);
1279  
1280          // Now, get the summary.
1281          $result = mod_quiz_external::get_attempt_summary($attempt->id);
1282          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1283  
1284          // Check it's marked as completed only the first one.
1285          $this->assertEquals('complete', $result['questions'][0]['state']);
1286          $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1287          $this->assertEquals('complete', $result['questions'][1]['state']);
1288          $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1289  
1290      }
1291  
1292      /**
1293       * Test process_attempt
1294       */
1295      public function test_process_attempt() {
1296          global $DB;
1297  
1298          $timenow = time();
1299          // Create a new quiz with three questions and one attempt started.
1300          list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false,
1301              'deferredfeedback', true);
1302  
1303          // Response for slot 1.
1304          $prefix = $quba->get_field_prefix(1);
1305          $data = array(
1306              array('name' => 'slots', 'value' => 1),
1307              array('name' => $prefix . ':sequencecheck',
1308                      'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()),
1309              array('name' => $prefix . 'answer', 'value' => 1),
1310          );
1311  
1312          $this->setUser($this->student);
1313  
1314          $result = mod_quiz_external::process_attempt($attempt->id, $data);
1315          $result = \external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1316          $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']);
1317  
1318          $result = mod_quiz_external::get_attempt_data($attempt->id, 2);
1319  
1320          // Now, get the summary.
1321          $result = mod_quiz_external::get_attempt_summary($attempt->id);
1322          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1323  
1324          // Check it's marked as completed only the first one.
1325          $this->assertEquals('complete', $result['questions'][0]['state']);
1326          $this->assertEquals('todo', $result['questions'][1]['state']);
1327          $this->assertEquals(1, $result['questions'][0]['number']);
1328          $this->assertEquals(2, $result['questions'][1]['number']);
1329          $this->assertFalse($result['questions'][0]['flagged']);
1330          $this->assertFalse($result['questions'][1]['flagged']);
1331          $this->assertEmpty($result['questions'][0]['mark']);
1332          $this->assertEmpty($result['questions'][1]['mark']);
1333          $this->assertEquals(2, $result['questions'][0]['sequencecheck']);
1334          $this->assertEquals(2, $result['questions'][0]['sequencecheck']);
1335          $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1336          $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1337          $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1338          $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1339  
1340          // Now, second slot.
1341          $prefix = $quba->get_field_prefix(2);
1342          $data = array(
1343              array('name' => 'slots', 'value' => 2),
1344              array('name' => $prefix . ':sequencecheck',
1345                      'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()),
1346              array('name' => $prefix . 'answer', 'value' => 1),
1347              array('name' => $prefix . ':flagged', 'value' => 1),
1348          );
1349  
1350          $result = mod_quiz_external::process_attempt($attempt->id, $data);
1351          $result = \external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1352          $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']);
1353  
1354          // Now, get the summary.
1355          $result = mod_quiz_external::get_attempt_summary($attempt->id);
1356          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1357  
1358          // Check it's marked as completed the two first questions.
1359          $this->assertEquals('complete', $result['questions'][0]['state']);
1360          $this->assertEquals('complete', $result['questions'][1]['state']);
1361          $this->assertFalse($result['questions'][0]['flagged']);
1362          $this->assertTrue($result['questions'][1]['flagged']);
1363  
1364          // Add files in the attachment response.
1365          $draftitemid = file_get_unused_draft_itemid();
1366          $filerecordinline = array(
1367              'contextid' => \context_user::instance($this->student->id)->id,
1368              'component' => 'user',
1369              'filearea'  => 'draft',
1370              'itemid'    => $draftitemid,
1371              'filepath'  => '/',
1372              'filename'  => 'faketxt.txt',
1373          );
1374          $fs = get_file_storage();
1375          $fs->create_file_from_string($filerecordinline, 'fake txt contents 1.');
1376  
1377          // Last slot.
1378          $prefix = $quba->get_field_prefix(3);
1379          $data = array(
1380              array('name' => 'slots', 'value' => 3),
1381              array('name' => $prefix . ':sequencecheck',
1382                      'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()),
1383              array('name' => $prefix . 'answer', 'value' => 'Some test'),
1384              array('name' => $prefix . 'answerformat', 'value' => FORMAT_HTML),
1385              array('name' => $prefix . 'attachments', 'value' => $draftitemid),
1386          );
1387  
1388          $result = mod_quiz_external::process_attempt($attempt->id, $data);
1389          $result = \external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1390          $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']);
1391  
1392          // Now, get the summary.
1393          $result = mod_quiz_external::get_attempt_summary($attempt->id);
1394          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1395  
1396          $this->assertEquals('complete', $result['questions'][0]['state']);
1397          $this->assertEquals('complete', $result['questions'][1]['state']);
1398          $this->assertEquals('complete', $result['questions'][2]['state']);
1399          $this->assertFalse($result['questions'][0]['flagged']);
1400          $this->assertTrue($result['questions'][1]['flagged']);
1401          $this->assertFalse($result['questions'][2]['flagged']);
1402  
1403          // Check submitted files are there.
1404          $this->assertCount(1, $result['questions'][2]['responsefileareas']);
1405          $this->assertEquals('attachments', $result['questions'][2]['responsefileareas'][0]['area']);
1406          $this->assertCount(1, $result['questions'][2]['responsefileareas'][0]['files']);
1407          $this->assertEquals($filerecordinline['filename'], $result['questions'][2]['responsefileareas'][0]['files'][0]['filename']);
1408  
1409          // Finish the attempt.
1410          $sink = $this->redirectMessages();
1411          $result = mod_quiz_external::process_attempt($attempt->id, array(), true);
1412          $result = \external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1413          $this->assertEquals(quiz_attempt::FINISHED, $result['state']);
1414          $messages = $sink->get_messages();
1415          $message = reset($messages);
1416          $sink->close();
1417          // Test customdata.
1418          if (!empty($message->customdata)) {
1419              $customdata = json_decode($message->customdata);
1420              $this->assertEquals($quizobj->get_quizid(), $customdata->instance);
1421              $this->assertEquals($quizobj->get_cmid(), $customdata->cmid);
1422              $this->assertEquals($attempt->id, $customdata->attemptid);
1423              $this->assertObjectHasAttribute('notificationiconurl', $customdata);
1424          }
1425  
1426          // Start new attempt.
1427          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1428          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1429  
1430          $timenow = time();
1431          $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
1432          quiz_start_new_attempt($quizobj, $quba, $attempt, 2, $timenow);
1433          quiz_attempt_save_started($quizobj, $quba, $attempt);
1434  
1435          // Force grace period, attempt going to overdue.
1436          $quiz->timeclose = $timenow - 10;
1437          $quiz->graceperiod = 60;
1438          $quiz->overduehandling = 'graceperiod';
1439          $DB->update_record('quiz', $quiz);
1440  
1441          $result = mod_quiz_external::process_attempt($attempt->id, array());
1442          $result = \external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1443          $this->assertEquals(quiz_attempt::OVERDUE, $result['state']);
1444  
1445          // Force grace period for time limit.
1446          $quiz->timeclose = 0;
1447          $quiz->timelimit = 1;
1448          $quiz->graceperiod = 60;
1449          $quiz->overduehandling = 'graceperiod';
1450          $DB->update_record('quiz', $quiz);
1451  
1452          $timenow = time();
1453          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1454          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1455          $attempt = quiz_create_attempt($quizobj, 3, 2, $timenow - 10, false, $this->student->id);
1456          quiz_start_new_attempt($quizobj, $quba, $attempt, 2, $timenow - 10);
1457          quiz_attempt_save_started($quizobj, $quba, $attempt);
1458  
1459          $result = mod_quiz_external::process_attempt($attempt->id, array());
1460          $result = \external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1461          $this->assertEquals(quiz_attempt::OVERDUE, $result['state']);
1462  
1463          // New attempt.
1464          $timenow = time();
1465          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1466          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1467          $attempt = quiz_create_attempt($quizobj, 4, 3, $timenow, false, $this->student->id);
1468          quiz_start_new_attempt($quizobj, $quba, $attempt, 3, $timenow);
1469          quiz_attempt_save_started($quizobj, $quba, $attempt);
1470  
1471          // Force abandon.
1472          $quiz->timeclose = $timenow - HOURSECS;
1473          $DB->update_record('quiz', $quiz);
1474  
1475          $result = mod_quiz_external::process_attempt($attempt->id, array());
1476          $result = \external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1477          $this->assertEquals(quiz_attempt::ABANDONED, $result['state']);
1478  
1479      }
1480  
1481      /**
1482       * Test validate_attempt_review
1483       */
1484      public function test_validate_attempt_review() {
1485          global $DB;
1486  
1487          // Create a new quiz with one attempt started.
1488          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
1489  
1490          $this->setUser($this->student);
1491  
1492          // Invalid attempt, invalid id.
1493          try {
1494              $params = array('attemptid' => -1);
1495              testable_mod_quiz_external::validate_attempt_review($params);
1496              $this->fail('Exception expected due invalid id.');
1497          } catch (\dml_missing_record_exception $e) {
1498              $this->assertEquals('invalidrecord', $e->errorcode);
1499          }
1500  
1501          // Invalid attempt, not closed.
1502          try {
1503              $params = array('attemptid' => $attempt->id);
1504              testable_mod_quiz_external::validate_attempt_review($params);
1505              $this->fail('Exception expected due not closed attempt.');
1506          } catch (\moodle_quiz_exception $e) {
1507              $this->assertEquals('attemptclosed', $e->errorcode);
1508          }
1509  
1510          // Test ok case (finished attempt).
1511          list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true, true);
1512  
1513          $params = array('attemptid' => $attempt->id);
1514          testable_mod_quiz_external::validate_attempt_review($params);
1515  
1516          // Teacher should be able to view the review of one student's attempt.
1517          $this->setUser($this->teacher);
1518          testable_mod_quiz_external::validate_attempt_review($params);
1519  
1520          // We should not see other students attempts.
1521          $anotherstudent = self::getDataGenerator()->create_user();
1522          $this->getDataGenerator()->enrol_user($anotherstudent->id, $this->course->id, $this->studentrole->id, 'manual');
1523  
1524          $this->setUser($anotherstudent);
1525          try {
1526              $params = array('attemptid' => $attempt->id);
1527              testable_mod_quiz_external::validate_attempt_review($params);
1528              $this->fail('Exception expected due missing permissions.');
1529          } catch (\moodle_quiz_exception $e) {
1530              $this->assertEquals('noreviewattempt', $e->errorcode);
1531          }
1532      }
1533  
1534  
1535      /**
1536       * Test get_attempt_review
1537       */
1538      public function test_get_attempt_review() {
1539          global $DB;
1540  
1541          // Create a new quiz with two questions and one attempt finished.
1542          list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, true);
1543  
1544          // Add feedback to the quiz.
1545          $feedback = new \stdClass();
1546          $feedback->quizid = $quiz->id;
1547          $feedback->feedbacktext = 'Feedback text 1';
1548          $feedback->feedbacktextformat = 1;
1549          $feedback->mingrade = 49;
1550          $feedback->maxgrade = 100;
1551          $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1552  
1553          $feedback->feedbacktext = 'Feedback text 2';
1554          $feedback->feedbacktextformat = 1;
1555          $feedback->mingrade = 30;
1556          $feedback->maxgrade = 48;
1557          $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1558  
1559          $result = mod_quiz_external::get_attempt_review($attempt->id);
1560          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result);
1561  
1562          // Two questions, one completed and correct, the other gave up.
1563          $this->assertEquals(50, $result['grade']);
1564          $this->assertEquals(1, $result['attempt']['attempt']);
1565          $this->assertEquals('finished', $result['attempt']['state']);
1566          $this->assertEquals(1, $result['attempt']['sumgrades']);
1567          $this->assertCount(2, $result['questions']);
1568          $this->assertEquals('gradedright', $result['questions'][0]['state']);
1569          $this->assertEquals(1, $result['questions'][0]['slot']);
1570          $this->assertEquals('gaveup', $result['questions'][1]['state']);
1571          $this->assertEquals(2, $result['questions'][1]['slot']);
1572  
1573          $this->assertCount(1, $result['additionaldata']);
1574          $this->assertEquals('feedback', $result['additionaldata'][0]['id']);
1575          $this->assertEquals('Feedback', $result['additionaldata'][0]['title']);
1576          $this->assertEquals('Feedback text 1', $result['additionaldata'][0]['content']);
1577  
1578          // Only first page.
1579          $result = mod_quiz_external::get_attempt_review($attempt->id, 0);
1580          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result);
1581  
1582          $this->assertEquals(50, $result['grade']);
1583          $this->assertEquals(1, $result['attempt']['attempt']);
1584          $this->assertEquals('finished', $result['attempt']['state']);
1585          $this->assertEquals(1, $result['attempt']['sumgrades']);
1586          $this->assertCount(1, $result['questions']);
1587          $this->assertEquals('gradedright', $result['questions'][0]['state']);
1588          $this->assertEquals(1, $result['questions'][0]['slot']);
1589  
1590           $this->assertCount(1, $result['additionaldata']);
1591          $this->assertEquals('feedback', $result['additionaldata'][0]['id']);
1592          $this->assertEquals('Feedback', $result['additionaldata'][0]['title']);
1593          $this->assertEquals('Feedback text 1', $result['additionaldata'][0]['content']);
1594  
1595      }
1596  
1597      /**
1598       * Test test_view_attempt
1599       */
1600      public function test_view_attempt() {
1601          global $DB;
1602  
1603          // Create a new quiz with two questions and one attempt started.
1604          list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false);
1605  
1606          // Test user with full capabilities.
1607          $this->setUser($this->student);
1608  
1609          // Trigger and capture the event.
1610          $sink = $this->redirectEvents();
1611  
1612          $result = mod_quiz_external::view_attempt($attempt->id, 0);
1613          $result = \external_api::clean_returnvalue(mod_quiz_external::view_attempt_returns(), $result);
1614          $this->assertTrue($result['status']);
1615  
1616          $events = $sink->get_events();
1617          $this->assertCount(1, $events);
1618          $event = array_shift($events);
1619  
1620          // Checking that the event contains the expected values.
1621          $this->assertInstanceOf('\mod_quiz\event\attempt_viewed', $event);
1622          $this->assertEquals($context, $event->get_context());
1623          $this->assertEventContextNotUsed($event);
1624          $this->assertNotEmpty($event->get_name());
1625  
1626          // Now, force the quiz with QUIZ_NAVMETHOD_SEQ (sequential) navigation method.
1627          $DB->set_field('quiz', 'navmethod', QUIZ_NAVMETHOD_SEQ, array('id' => $quiz->id));
1628          // Quiz requiring preflightdata.
1629          $DB->set_field('quiz', 'password', 'abcdef', array('id' => $quiz->id));
1630          $preflightdata = array(array("name" => "quizpassword", "value" => 'abcdef'));
1631  
1632          // See next page.
1633          $result = mod_quiz_external::view_attempt($attempt->id, 1, $preflightdata);
1634          $result = \external_api::clean_returnvalue(mod_quiz_external::view_attempt_returns(), $result);
1635          $this->assertTrue($result['status']);
1636  
1637          $events = $sink->get_events();
1638          $this->assertCount(2, $events);
1639  
1640          // Try to go to previous page.
1641          try {
1642              mod_quiz_external::view_attempt($attempt->id, 0);
1643              $this->fail('Exception expected due to try to see a previous page.');
1644          } catch (\moodle_quiz_exception $e) {
1645              $this->assertEquals('Out of sequence access', $e->errorcode);
1646          }
1647  
1648      }
1649  
1650      /**
1651       * Test test_view_attempt_summary
1652       */
1653      public function test_view_attempt_summary() {
1654          global $DB;
1655  
1656          // Create a new quiz with two questions and one attempt started.
1657          list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false);
1658  
1659          // Test user with full capabilities.
1660          $this->setUser($this->student);
1661  
1662          // Trigger and capture the event.
1663          $sink = $this->redirectEvents();
1664  
1665          $result = mod_quiz_external::view_attempt_summary($attempt->id);
1666          $result = \external_api::clean_returnvalue(mod_quiz_external::view_attempt_summary_returns(), $result);
1667          $this->assertTrue($result['status']);
1668  
1669          $events = $sink->get_events();
1670          $this->assertCount(1, $events);
1671          $event = array_shift($events);
1672  
1673          // Checking that the event contains the expected values.
1674          $this->assertInstanceOf('\mod_quiz\event\attempt_summary_viewed', $event);
1675          $this->assertEquals($context, $event->get_context());
1676          $moodlequiz = new \moodle_url('/mod/quiz/summary.php', array('attempt' => $attempt->id));
1677          $this->assertEquals($moodlequiz, $event->get_url());
1678          $this->assertEventContextNotUsed($event);
1679          $this->assertNotEmpty($event->get_name());
1680  
1681          // Quiz requiring preflightdata.
1682          $DB->set_field('quiz', 'password', 'abcdef', array('id' => $quiz->id));
1683          $preflightdata = array(array("name" => "quizpassword", "value" => 'abcdef'));
1684  
1685          $result = mod_quiz_external::view_attempt_summary($attempt->id, $preflightdata);
1686          $result = \external_api::clean_returnvalue(mod_quiz_external::view_attempt_summary_returns(), $result);
1687          $this->assertTrue($result['status']);
1688  
1689      }
1690  
1691      /**
1692       * Test test_view_attempt_summary
1693       */
1694      public function test_view_attempt_review() {
1695          global $DB;
1696  
1697          // Create a new quiz with two questions and one attempt finished.
1698          list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, true);
1699  
1700          // Test user with full capabilities.
1701          $this->setUser($this->student);
1702  
1703          // Trigger and capture the event.
1704          $sink = $this->redirectEvents();
1705  
1706          $result = mod_quiz_external::view_attempt_review($attempt->id, 0);
1707          $result = \external_api::clean_returnvalue(mod_quiz_external::view_attempt_review_returns(), $result);
1708          $this->assertTrue($result['status']);
1709  
1710          $events = $sink->get_events();
1711          $this->assertCount(1, $events);
1712          $event = array_shift($events);
1713  
1714          // Checking that the event contains the expected values.
1715          $this->assertInstanceOf('\mod_quiz\event\attempt_reviewed', $event);
1716          $this->assertEquals($context, $event->get_context());
1717          $moodlequiz = new \moodle_url('/mod/quiz/review.php', array('attempt' => $attempt->id));
1718          $this->assertEquals($moodlequiz, $event->get_url());
1719          $this->assertEventContextNotUsed($event);
1720          $this->assertNotEmpty($event->get_name());
1721  
1722      }
1723  
1724      /**
1725       * Test get_quiz_feedback_for_grade
1726       */
1727      public function test_get_quiz_feedback_for_grade() {
1728          global $DB;
1729  
1730          // Add feedback to the quiz.
1731          $feedback = new \stdClass();
1732          $feedback->quizid = $this->quiz->id;
1733          $feedback->feedbacktext = 'Feedback text 1';
1734          $feedback->feedbacktextformat = 1;
1735          $feedback->mingrade = 49;
1736          $feedback->maxgrade = 100;
1737          $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1738          // Add a fake inline image to the feedback text.
1739          $filename = 'shouldbeanimage.jpg';
1740          $filerecordinline = array(
1741              'contextid' => $this->context->id,
1742              'component' => 'mod_quiz',
1743              'filearea'  => 'feedback',
1744              'itemid'    => $feedback->id,
1745              'filepath'  => '/',
1746              'filename'  => $filename,
1747          );
1748          $fs = get_file_storage();
1749          $fs->create_file_from_string($filerecordinline, 'image contents (not really)');
1750  
1751          $feedback->feedbacktext = 'Feedback text 2';
1752          $feedback->feedbacktextformat = 1;
1753          $feedback->mingrade = 30;
1754          $feedback->maxgrade = 49;
1755          $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1756  
1757          $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 50);
1758          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result);
1759          $this->assertEquals('Feedback text 1', $result['feedbacktext']);
1760          $this->assertEquals($filename, $result['feedbackinlinefiles'][0]['filename']);
1761          $this->assertEquals(FORMAT_HTML, $result['feedbacktextformat']);
1762  
1763          $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 30);
1764          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result);
1765          $this->assertEquals('Feedback text 2', $result['feedbacktext']);
1766          $this->assertEquals(FORMAT_HTML, $result['feedbacktextformat']);
1767  
1768          $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 10);
1769          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result);
1770          $this->assertEquals('', $result['feedbacktext']);
1771          $this->assertEquals(FORMAT_MOODLE, $result['feedbacktextformat']);
1772      }
1773  
1774      /**
1775       * Test get_quiz_access_information
1776       */
1777      public function test_get_quiz_access_information() {
1778          global $DB;
1779  
1780          // Create a new quiz.
1781          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
1782          $data = array('course' => $this->course->id);
1783          $quiz = $quizgenerator->create_instance($data);
1784  
1785          $this->setUser($this->student);
1786  
1787          // Default restrictions (none).
1788          $result = mod_quiz_external::get_quiz_access_information($quiz->id);
1789          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result);
1790  
1791          $expected = array(
1792              'canattempt' => true,
1793              'canmanage' => false,
1794              'canpreview' => false,
1795              'canreviewmyattempts' => true,
1796              'canviewreports' => false,
1797              'accessrules' => [],
1798              // This rule is always used, even if the quiz has no open or close date.
1799              'activerulenames' => ['quizaccess_openclosedate'],
1800              'preventaccessreasons' => [],
1801              'warnings' => []
1802          );
1803  
1804          $this->assertEquals($expected, $result);
1805  
1806          // Now teacher, different privileges.
1807          $this->setUser($this->teacher);
1808          $result = mod_quiz_external::get_quiz_access_information($quiz->id);
1809          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result);
1810  
1811          $expected['canmanage'] = true;
1812          $expected['canpreview'] = true;
1813          $expected['canviewreports'] = true;
1814          $expected['canattempt'] = false;
1815          $expected['canreviewmyattempts'] = false;
1816  
1817          $this->assertEquals($expected, $result);
1818  
1819          $this->setUser($this->student);
1820          // Now add some restrictions.
1821          $quiz->timeopen = time() + DAYSECS;
1822          $quiz->timeclose = time() + WEEKSECS;
1823          $quiz->password = '123456';
1824          $DB->update_record('quiz', $quiz);
1825  
1826          $result = mod_quiz_external::get_quiz_access_information($quiz->id);
1827          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result);
1828  
1829          // Access is limited by time and password, but only the password limit has a description.
1830          $this->assertCount(1, $result['accessrules']);
1831          // Two rule names, password and open/close date.
1832          $this->assertCount(2, $result['activerulenames']);
1833          $this->assertCount(1, $result['preventaccessreasons']);
1834  
1835      }
1836  
1837      /**
1838       * Test get_attempt_access_information
1839       */
1840      public function test_get_attempt_access_information() {
1841          global $DB;
1842  
1843          $this->setAdminUser();
1844  
1845          // Create a new quiz with attempts.
1846          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
1847          $data = array('course' => $this->course->id,
1848                        'sumgrades' => 2);
1849          $quiz = $quizgenerator->create_instance($data);
1850  
1851          // Create some questions.
1852          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
1853  
1854          $cat = $questiongenerator->create_question_category();
1855          $question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
1856          quiz_add_quiz_question($question->id, $quiz);
1857  
1858          $question = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
1859          quiz_add_quiz_question($question->id, $quiz);
1860  
1861          // Add new question types in the category (for the random one).
1862          $question = $questiongenerator->create_question('truefalse', null, array('category' => $cat->id));
1863          $question = $questiongenerator->create_question('essay', null, array('category' => $cat->id));
1864  
1865          quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
1866  
1867          $quizobj = quiz::create($quiz->id, $this->student->id);
1868  
1869          // Set grade to pass.
1870          $item = \grade_item::fetch(array('courseid' => $this->course->id, 'itemtype' => 'mod',
1871                                          'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
1872          $item->gradepass = 80;
1873          $item->update();
1874  
1875          $this->setUser($this->student);
1876  
1877          // Default restrictions (none).
1878          $result = mod_quiz_external::get_attempt_access_information($quiz->id);
1879          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_access_information_returns(), $result);
1880  
1881          $expected = array(
1882              'isfinished' => false,
1883              'preventnewattemptreasons' => [],
1884              'warnings' => []
1885          );
1886  
1887          $this->assertEquals($expected, $result);
1888  
1889          // Limited attempts.
1890          $quiz->attempts = 1;
1891          $DB->update_record('quiz', $quiz);
1892  
1893          // Now, do one attempt.
1894          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1895          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1896  
1897          $timenow = time();
1898          $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
1899          quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
1900          quiz_attempt_save_started($quizobj, $quba, $attempt);
1901  
1902          // Process some responses from the student.
1903          $attemptobj = quiz_attempt::create($attempt->id);
1904          $tosubmit = array(1 => array('answer' => '3.14'));
1905          $attemptobj->process_submitted_actions($timenow, false, $tosubmit);
1906  
1907          // Finish the attempt.
1908          $attemptobj = quiz_attempt::create($attempt->id);
1909          $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question());
1910          $attemptobj->process_finish($timenow, false);
1911  
1912          // Can we start a new attempt? We shall not!
1913          $result = mod_quiz_external::get_attempt_access_information($quiz->id, $attempt->id);
1914          $result = \external_api::clean_returnvalue(mod_quiz_external::get_attempt_access_information_returns(), $result);
1915  
1916          // Now new attemps allowed.
1917          $this->assertCount(1, $result['preventnewattemptreasons']);
1918          $this->assertFalse($result['ispreflightcheckrequired']);
1919          $this->assertEquals(get_string('nomoreattempts', 'quiz'), $result['preventnewattemptreasons'][0]);
1920  
1921      }
1922  
1923      /**
1924       * Test get_quiz_required_qtypes
1925       */
1926      public function test_get_quiz_required_qtypes() {
1927          $this->setAdminUser();
1928  
1929          // Create a new quiz.
1930          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
1931          $data = array('course' => $this->course->id);
1932          $quiz = $quizgenerator->create_instance($data);
1933  
1934          // Create some questions.
1935          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
1936  
1937          $cat = $questiongenerator->create_question_category();
1938          $question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
1939          quiz_add_quiz_question($question->id, $quiz);
1940  
1941          $question = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
1942          quiz_add_quiz_question($question->id, $quiz);
1943  
1944          $question = $questiongenerator->create_question('truefalse', null, array('category' => $cat->id));
1945          quiz_add_quiz_question($question->id, $quiz);
1946  
1947          $question = $questiongenerator->create_question('essay', null, array('category' => $cat->id));
1948          quiz_add_quiz_question($question->id, $quiz);
1949  
1950          $question = $questiongenerator->create_question('multichoice', null,
1951                  ['category' => $cat->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
1952          quiz_add_quiz_question($question->id, $quiz);
1953  
1954          $this->setUser($this->student);
1955  
1956          $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id);
1957          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result);
1958  
1959          $expected = array(
1960              'questiontypes' => ['essay', 'numerical', 'shortanswer', 'truefalse'],
1961              'warnings' => []
1962          );
1963  
1964          $this->assertEquals($expected, $result);
1965  
1966      }
1967  
1968      /**
1969       * Test get_quiz_required_qtypes for quiz with random questions
1970       */
1971      public function test_get_quiz_required_qtypes_random() {
1972          $this->setAdminUser();
1973  
1974          // Create a new quiz.
1975          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
1976          $quiz = $quizgenerator->create_instance(['course' => $this->course->id]);
1977  
1978          // Create some questions.
1979          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
1980  
1981          $cat = $questiongenerator->create_question_category();
1982          $anothercat = $questiongenerator->create_question_category();
1983  
1984          $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
1985          $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
1986          $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]);
1987          // Question in a different category.
1988          $question = $questiongenerator->create_question('essay', null, ['category' => $anothercat->id]);
1989  
1990          // Add a couple of random questions from the same category.
1991          quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
1992          quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
1993  
1994          $this->setUser($this->student);
1995  
1996          $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id);
1997          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result);
1998  
1999          $expected = ['numerical', 'shortanswer', 'truefalse'];
2000          ksort($result['questiontypes']);
2001  
2002          $this->assertEquals($expected, $result['questiontypes']);
2003  
2004          // Add more questions to the quiz, this time from the other category.
2005          $this->setAdminUser();
2006          quiz_add_random_questions($quiz, 0, $anothercat->id, 1, false);
2007  
2008          $this->setUser($this->student);
2009          $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id);
2010          $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result);
2011  
2012          // The new question from the new category is returned as a potential random question for the quiz.
2013          $expected = ['essay', 'numerical', 'shortanswer', 'truefalse'];
2014          ksort($result['questiontypes']);
2015  
2016          $this->assertEquals($expected, $result['questiontypes']);
2017      }
2018  
2019      /**
2020       * Test that a sequential navigation quiz is not allowing to see questions in advance except if reviewing
2021       */
2022      public function test_sequential_navigation_view_attempt() {
2023          // Test user with full capabilities.
2024          $quiz = $this->prepare_sequential_quiz();
2025          $attemptobj = $this->create_quiz_attempt_object($quiz);
2026          $this->setUser($this->student);
2027          // Check out of sequence access for view.
2028          $this->assertNotEmpty(mod_quiz_external::view_attempt($attemptobj->get_attemptid(), 0, []));
2029          try {
2030              mod_quiz_external::view_attempt($attemptobj->get_attemptid(), 3, []);
2031              $this->fail('Exception expected due to out of sequence access.');
2032          } catch (\moodle_exception $e) {
2033              $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2034          }
2035      }
2036  
2037      /**
2038       * Test that a sequential navigation quiz is not allowing to see questions in advance for a student
2039       */
2040      public function test_sequential_navigation_attempt_summary() {
2041          // Test user with full capabilities.
2042          $quiz = $this->prepare_sequential_quiz();
2043          $attemptobj = $this->create_quiz_attempt_object($quiz);
2044          $this->setUser($this->student);
2045          // Check that we do not return other questions than the one currently viewed.
2046          $result = mod_quiz_external::get_attempt_summary($attemptobj->get_attemptid());
2047          $this->assertCount(1, $result['questions']);
2048          $this->assertStringContainsString('Question (1)', $result['questions'][0]['html']);
2049      }
2050  
2051      /**
2052       * Test that a sequential navigation quiz is not allowing to see questions in advance for student
2053       */
2054      public function test_sequential_navigation_get_attempt_data() {
2055          // Test user with full capabilities.
2056          $quiz = $this->prepare_sequential_quiz();
2057          $attemptobj = $this->create_quiz_attempt_object($quiz);
2058          $this->setUser($this->student);
2059          // Test invalid instance id.
2060          try {
2061              mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 2);
2062              $this->fail('Exception expected due to out of sequence access.');
2063          } catch (\moodle_exception $e) {
2064              $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2065          }
2066          // Now we moved to page 1, we should see page 2 and 1 but not 0 or 3.
2067          $attemptobj->set_currentpage(1);
2068          // Test invalid instance id.
2069          try {
2070              mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 0);
2071              $this->fail('Exception expected due to out of sequence access.');
2072          } catch (\moodle_exception $e) {
2073              $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2074          }
2075  
2076          try {
2077              mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 3);
2078              $this->fail('Exception expected due to out of sequence access.');
2079          } catch (\moodle_exception $e) {
2080              $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2081          }
2082  
2083          // Now we can see page 1.
2084          $result = mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 1);
2085          $this->assertCount(1, $result['questions']);
2086          $this->assertStringContainsString('Question (2)', $result['questions'][0]['html']);
2087      }
2088  
2089      /**
2090       * Prepare quiz for sequential navigation tests
2091       *
2092       * @return quiz
2093       */
2094      private function prepare_sequential_quiz(): quiz {
2095          // Create a new quiz with 5 questions and one attempt started.
2096          // Create a new quiz with attempts.
2097          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
2098          $data = [
2099              'course' => $this->course->id,
2100              'sumgrades' => 2,
2101              'preferredbehaviour' => 'deferredfeedback',
2102              'navmethod' => QUIZ_NAVMETHOD_SEQ
2103          ];
2104          $quiz = $quizgenerator->create_instance($data);
2105  
2106          // Now generate the questions.
2107          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
2108          $cat = $questiongenerator->create_question_category();
2109          for ($pageindex = 1; $pageindex <= 5; $pageindex++) {
2110              $question = $questiongenerator->create_question('truefalse', null, [
2111                  'category' => $cat->id,
2112                  'questiontext' => ['text' => "Question ($pageindex)"]
2113              ]);
2114              quiz_add_quiz_question($question->id, $quiz, $pageindex);
2115          }
2116  
2117          $quizobj = quiz::create($quiz->id, $this->student->id);
2118          // Set grade to pass.
2119          $item = \grade_item::fetch(array('courseid' => $this->course->id, 'itemtype' => 'mod',
2120              'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
2121          $item->gradepass = 80;
2122          $item->update();
2123          return $quizobj;
2124      }
2125  
2126      /**
2127       * Create question attempt
2128       *
2129       * @param quiz $quizobj
2130       * @param int|null $userid
2131       * @param bool|null $ispreview
2132       * @return quiz_attempt
2133       * @throws \moodle_exception
2134       */
2135      private function create_quiz_attempt_object(quiz $quizobj, ?int $userid = null, ?bool $ispreview = false): quiz_attempt {
2136          global $USER;
2137          $timenow = time();
2138          // Now, do one attempt.
2139          $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
2140          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
2141          $attemptnumber = 1;
2142          if (!empty($USER->id)) {
2143              $attemptnumber = count(quiz_get_user_attempts($quizobj->get_quizid(), $USER->id)) + 1;
2144          }
2145          $attempt = quiz_create_attempt($quizobj, $attemptnumber, false, $timenow, $ispreview, $userid ?? $this->student->id);
2146          quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow);
2147          quiz_attempt_save_started($quizobj, $quba, $attempt);
2148          $attemptobj = quiz_attempt::create($attempt->id);
2149          return $attemptobj;
2150      }
2151  }