Search moodle.org's
Developer Documentation

See Release Notes

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

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