Search moodle.org's
Developer Documentation

See Release Notes

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

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