Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

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

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

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