Search moodle.org's
Developer Documentation

See Release Notes

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

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

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace enrol_lti\local\ltiadvantage\task;
  18  
  19  use enrol_lti\helper;
  20  use Packback\Lti1p3\LtiAssignmentsGradesService;
  21  use Packback\Lti1p3\LtiGrade;
  22  use Packback\Lti1p3\LtiLineitem;
  23  
  24  defined('MOODLE_INTERNAL') || die();
  25  
  26  require_once (__DIR__ . '/../lti_advantage_testcase.php');
  27  
  28  /**
  29   * Tests for the enrol_lti\local\ltiadvantage\task\sync_grades scheduled task.
  30   *
  31   * @package enrol_lti
  32   * @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
  33   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34   * @coversDefaultClass \enrol_lti\local\ltiadvantage\task\sync_grades
  35   */
  36  class sync_grades_test extends \lti_advantage_testcase {
  37      /**
  38       * Get a task which has a mocked ags instance injected, meaning no real calls will be made to the platform.
  39       *
  40       * This allows us to test the behaviour of the task (in terms of which users are in scope and which grades are sent)
  41       * without needing to deal with any auth.
  42       *
  43       * @param string $statuscode the HTTP status code to simulate.
  44       * @param bool $mockexception whether to simulate an exception during the service call or not.
  45       * @return sync_grades instance of the task with a mocked ags instance inside.
  46       */
  47      protected function get_task_with_mocked_grade_service($statuscode = '200', $mockexception = false): sync_grades {
  48          $mockgradeservice = $this->getMockBuilder(LtiAssignmentsGradesService::class)
  49              ->disableOriginalConstructor()
  50              ->onlyMethods(['putGrade'])
  51              ->getMock();
  52          $mockgradeservice->method('putGrade')->willReturnCallback(function() use ($statuscode, $mockexception) {
  53              if ($mockexception) {
  54                  throw new \Exception();
  55              }
  56              return ['headers' => ['httpstatus' => "HTTP/2 $statuscode OK"], 'body' => '', 'status' => $statuscode];
  57          });
  58          // Get a mock task, with the method 'get_ags()' mocked to return the mocked AGS instance.
  59          $mocktask = $this->getMockBuilder(sync_grades::class)
  60              ->onlyMethods(['get_ags'])
  61              ->getMock();
  62          $mocktask->method('get_ags')->willReturn($mockgradeservice);
  63          return $mocktask;
  64      }
  65  
  66      /**
  67       * Helper function to set a grade for a user.
  68       *
  69       * @param int $userid the id of the user being graded.
  70       * @param float $grade the grade value, out of 100, to set for the user.
  71       * @param \stdClass $resource the published resource object.
  72       * @return float the fractional grade value expected to be used during sync.
  73       */
  74      protected function set_user_grade_for_resource(int $userid, float $grade, \stdClass $resource): float {
  75  
  76          global $CFG;
  77          require_once($CFG->libdir . '/accesslib.php');
  78          require_once($CFG->libdir . '/gradelib.php');
  79          $context = \context::instance_by_id($resource->contextid);
  80  
  81          if ($context->contextlevel == CONTEXT_COURSE) {
  82              $gi = \grade_item::fetch_course_item($resource->courseid);
  83          } else if ($context->contextlevel == CONTEXT_MODULE) {
  84              $cm = get_coursemodule_from_id('assign', $context->instanceid);
  85  
  86              $gi = \grade_item::fetch([
  87                  'itemtype' => 'mod',
  88                  'itemmodule' => 'assign',
  89                  'iteminstance' => $cm->instance,
  90                  'courseid' => $resource->courseid
  91              ]);
  92          }
  93  
  94          if ($ggrade = \grade_grade::fetch(['itemid' => $gi->id, 'userid' => $userid])) {
  95              $ggrade->finalgrade = $grade;
  96              $ggrade->rawgrade = $grade;
  97              $ggrade->update();
  98          } else {
  99              $ggrade = new \grade_grade();
 100              $ggrade->itemid = $gi->id;
 101              $ggrade->userid = $userid;
 102              $ggrade->rawgrade = $grade;
 103              $ggrade->finalgrade = $grade;
 104              $ggrade->rawgrademax = 100;
 105              $ggrade->rawgrademin = 0;
 106              $ggrade->timecreated = time();
 107              $ggrade->timemodified = time();
 108              $ggrade->insert();
 109          }
 110          return floatval($ggrade->finalgrade / $gi->grademax);
 111      }
 112  
 113      /**
 114       * Helper to set the completion status for published course or module.
 115       *
 116       * @param \stdClass $resource the resource - either a course or module.
 117       * @param int $userid the id of the user to override the completion status for.
 118       * @param bool $complete whether the resource is deemed complete or not.
 119       */
 120      protected function override_resource_completion_status_for_user(\stdClass $resource, int $userid,
 121              bool $complete): void {
 122  
 123          global $CFG;
 124          require_once($CFG->libdir . '/accesslib.php');
 125          require_once($CFG->libdir . '/completionlib.php');
 126          require_once($CFG->libdir . '/datalib.php');
 127          $this->setAdminUser();
 128          $context = \context::instance_by_id($resource->contextid);
 129          $completion = new \completion_info(get_course($resource->courseid));
 130          if ($context->contextlevel == CONTEXT_COURSE) {
 131              $ccompletion = new \completion_completion(['userid' => $userid, 'course' => $resource->courseid]);
 132              if ($complete) {
 133                  $ccompletion->mark_complete();
 134              } else {
 135                  $completion->clear_criteria();
 136              }
 137          } else if ($context->contextlevel == CONTEXT_MODULE) {
 138              $completion->update_state(
 139                  get_coursemodule_from_id('assign', $context->instanceid),
 140                  $complete ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE,
 141                  $userid,
 142                  true
 143              );
 144          }
 145      }
 146  
 147      /**
 148       * Test confirming task name.
 149       *
 150       * @covers ::get_name
 151       */
 152      public function test_get_name() {
 153          $this->assertEquals(get_string('tasksyncgrades', 'enrol_lti'), (new sync_grades())->get_name());
 154      }
 155  
 156      /**
 157       * Test the sync grades task during several runs and for a series of grade changes.
 158       *
 159       * @covers ::execute
 160       */
 161      public function test_grade_sync_chronological_syncs() {
 162          $this->resetAfterTest();
 163  
 164          [$course, $resource] = $this->create_test_environment();
 165          $launchservice = $this->get_tool_launch_service();
 166          $task = $this->get_task_with_mocked_grade_service();
 167  
 168          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 169          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 170          $instructoruser = $this->getDataGenerator()->create_user();
 171          [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 172  
 173          // Launch the resource for a few more users, creating those enrolments and allowing grading to take place.
 174          $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false,
 175              'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner');
 176  
 177          $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]);
 178          $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]);
 179          $student1user = $this->getDataGenerator()->create_user();
 180          $student2user = $this->getDataGenerator()->create_user();
 181          [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch);
 182          [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch);
 183  
 184          // Grade student1 only.
 185          $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource);
 186  
 187          // Sync and verify that only student1's grade is sent.
 188          ob_start();
 189          $task->execute();
 190          $ob = ob_get_contents();
 191          ob_end_clean();
 192          $expectedtraces = [
 193              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 194              "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ".
 195                  "'$course->id'.",
 196              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 197                  "'$resource->id' and the course '$course->id' was sent.",
 198              "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ".
 199                  "'$course->id'.",
 200              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 201                  "Processed 3 users; sent 1 grades."
 202          ];
 203          foreach ($expectedtraces as $expectedtrace) {
 204              $this->assertStringContainsString($expectedtrace, $ob);
 205          }
 206  
 207          // Sync again, verifying no grades are sent because nothing has changed.
 208          ob_start();
 209          $task->execute();
 210          $ob = ob_get_contents();
 211          ob_end_clean();
 212          $expectedtraces = [
 213              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 214              "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ".
 215              "'$course->id'.",
 216              "Not sent - The grade for the user '$student1id', for the resource '$resource->id' and the course ".
 217                  "'$course->id' was not sent as the grades are the same.",
 218              "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ".
 219                  "'$course->id'.",
 220              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 221              "Processed 3 users; sent 0 grades."
 222          ];
 223          foreach ($expectedtraces as $expectedtrace) {
 224              $this->assertStringContainsString($expectedtrace, $ob);
 225          }
 226  
 227          // Change student1's grade and add a grade for student2.
 228          $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 68.5, $resource);
 229          $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 44.5, $resource);
 230  
 231          // Sync again, verifying both grade changes are sent.
 232          ob_start();
 233          $task->execute();
 234          $ob = ob_get_contents();
 235          ob_end_clean();
 236  
 237          $expectedtraces = [
 238              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 239              "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ".
 240                  "'$course->id'.",
 241              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 242                  "'$resource->id' and the course '$course->id' was sent.",
 243              "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ".
 244                  "'$resource->id' and the course '$course->id' was sent.",
 245              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 246                  "Processed 3 users; sent 2 grades."
 247          ];
 248          foreach ($expectedtraces as $expectedtrace) {
 249              $this->assertStringContainsString($expectedtrace, $ob);
 250          }
 251      }
 252  
 253      /**
 254       * Test a grade sync when there are more than one resource link for the resource.
 255       *
 256       * @covers ::execute
 257       */
 258      public function test_grade_sync_multiple_resource_links() {
 259          $this->resetAfterTest();
 260  
 261          [$course, $resource] = $this->create_test_environment();
 262          $launchservice = $this->get_tool_launch_service();
 263          $task = $this->get_task_with_mocked_grade_service();
 264  
 265          // Launch the resource first for an instructor using the default resource link in the platform.
 266          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 267          $instructoruser = $this->getDataGenerator()->create_user();
 268          [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 269  
 270          // Launch again as the instructor, this time from a different resource link in the platform.
 271          $teachermocklaunch2 = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], 'RLID-2');
 272          $launchservice->user_launches_tool($instructoruser, $teachermocklaunch2);
 273  
 274          // Launch the resource for a few more users, creating those enrolments and allowing grading to take place.
 275          $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false,
 276              'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner');
 277  
 278          $student1reslink1launch = $this->get_mock_launch($resource, $studentusers[0]);
 279          $student2reslink1launch = $this->get_mock_launch($resource, $studentusers[1]);
 280          $student1reslink2launch = $this->get_mock_launch($resource, $studentusers[1], 'RLID-2');
 281          $student1user = $this->getDataGenerator()->create_user();
 282          $student2user = $this->getDataGenerator()->create_user();
 283          [$student1id] = $launchservice->user_launches_tool($student1user, $student1reslink1launch);
 284          [$student2id] = $launchservice->user_launches_tool($student2user, $student2reslink1launch);
 285          $launchservice->user_launches_tool($student1user, $student1reslink2launch);
 286  
 287          // Grade student1 only.
 288          $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource);
 289  
 290          // Sync and verify that only student1's grade is sent but that it's sent for BOTH resource links.
 291          ob_start();
 292          $task->execute();
 293          $ob = ob_get_contents();
 294          ob_end_clean();
 295  
 296          $expectedtraces = [
 297              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 298              "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ".
 299              "'$course->id'.",
 300              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 301              "'$resource->id' and the course '$course->id' was sent.",
 302              "Found 2 resource link(s) for the user '$student1id', for the resource ".
 303              "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.",
 304              "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ".
 305              "'$course->id'.",
 306              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 307              "Processed 3 users; sent 1 grades."
 308          ];
 309          foreach ($expectedtraces as $expectedtrace) {
 310              $this->assertStringContainsString($expectedtrace, $ob);
 311          }
 312  
 313          // Verify that the grade was reported as being synced twice - once for each resource link.
 314          $expected = "/Found 2 resource link\(s\) for the user '$student1id', for the resource ".
 315              "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.\n".
 316              "Processing resource link '.*'.\n".
 317              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 318              "'$resource->id' and the course '$course->id' was sent.\n".
 319              "Processing resource link '.*'.\n".
 320              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 321              "'$resource->id' and the course '$course->id' was sent./";
 322          $this->assertMatchesRegularExpression($expected, $ob);
 323      }
 324  
 325      /**
 326       * Test grade sync when the resource has syncgrades disabled.
 327       *
 328       * @covers ::execute
 329       */
 330      public function test_sync_grades_gradesync_disabled() {
 331          $this->resetAfterTest();
 332          [$course, $resource] = $this->create_test_environment(true, true, true, helper::MEMBER_SYNC_ENROL_AND_UNENROL,
 333              false);
 334          $launchservice = $this->get_tool_launch_service();
 335  
 336          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 337          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 338          $instructoruser = $this->getDataGenerator()->create_user();
 339          $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 340  
 341          $task = $this->get_task_with_mocked_grade_service();
 342          $this->expectOutputRegex('/Skipping task - There are no resources with grade sync enabled./');
 343          $task->execute();
 344      }
 345  
 346      /**
 347       * Test the grade sync task when the auth_lti plugin is disabled.
 348       *
 349       * @covers ::execute
 350       */
 351      public function test_sync_grades_auth_plugin_disabled() {
 352          $this->resetAfterTest();
 353          [$course, $resource] = $this->create_test_environment(false);
 354          $launchservice = $this->get_tool_launch_service();
 355  
 356          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 357          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 358          $instructoruser = $this->getDataGenerator()->create_user();
 359          $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 360  
 361          $task = $this->get_task_with_mocked_grade_service();
 362          $this->expectOutputRegex('/Skipping task - ' .
 363              get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti')) . '/');
 364          $task->execute();
 365      }
 366  
 367      /**
 368       * Test the grade sync task when the enrol_lti plugin is disabled.
 369       *
 370       * @covers ::execute
 371       */
 372      public function test_sync_grades_enrol_plugin_disabled() {
 373          $this->resetAfterTest();
 374          [$course, $resource] = $this->create_test_environment(true, false);
 375          $launchservice = $this->get_tool_launch_service();
 376  
 377          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 378          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 379          $instructoruser = $this->getDataGenerator()->create_user();
 380          $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 381  
 382          $task = $this->get_task_with_mocked_grade_service();
 383          $this->expectOutputRegex('/Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti') . '/');
 384          $task->execute();
 385      }
 386  
 387      /**
 388       * Test the grade sync task when the launch data doesn't include the AGS support.
 389       *
 390       * @covers ::execute
 391       */
 392      public function test_sync_grades_no_service_endpoint() {
 393          $this->resetAfterTest();
 394          [$course, $resource] = $this->create_test_environment();
 395          $launchservice = $this->get_tool_launch_service();
 396  
 397          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 398          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0],
 399              null, null);
 400          $instructoruser = $this->getDataGenerator()->create_user();
 401          [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 402  
 403          $task = $this->get_task_with_mocked_grade_service();
 404          $this->expectOutputRegex(
 405              "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n".
 406              "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ".
 407              "course '$course->id'. Attempting to sync grades for all.\n".
 408              "Processing resource link '.*'.\n".
 409              "Skipping - No grade service found for the user '$userid', for the resource '$resource->id' and the ".
 410              "course '$course->id'.\n".
 411              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ".
 412              "sent 0 grades./"
 413          );
 414          $task->execute();
 415      }
 416  
 417      /**
 418       * Test syncing grades when the enrolment instance is disabled.
 419       *
 420       * @covers ::execute
 421       */
 422      public function test_sync_grades_disabled_instance() {
 423          $this->resetAfterTest();
 424          global $DB;
 425  
 426          [$course, $resource, $resource2, $resource3] = $this->create_test_environment();
 427  
 428          // Disable resource 1.
 429          $enrol = (object) ['id' => $resource->enrolid, 'status' => ENROL_INSTANCE_DISABLED];
 430          $DB->update_record('enrol', $enrol);
 431  
 432          // Delete the activity being shared by resource 2, leaving resource 2 disabled as a result.
 433          $modcontext = \context::instance_by_id($resource2->contextid);
 434          course_delete_module($modcontext->instanceid);
 435  
 436          // Only the enabled resource 3 should sync grades.
 437          $task = $this->get_task_with_mocked_grade_service();
 438          $this->expectOutputRegex(
 439              "/^Starting - LTI Advantage grade sync for shared resource '$resource3->id' in course '$course->id'.\n".
 440              "Completed - Synced grades for tool '$resource3->id' in the course '$course->id'. Processed 0 users; ".
 441              "sent 0 grades.\n$/"
 442          );
 443          $task->execute();
 444      }
 445  
 446      /**
 447       * Test the grade sync when the context has been deleted in between launch and when the grade sync task is run.
 448       *
 449       * @covers ::execute
 450       */
 451      public function test_sync_grades_deleted_context() {
 452          $this->resetAfterTest();
 453          global $DB;
 454  
 455          [$course, $resource] = $this->create_test_environment();
 456          $launchservice = $this->get_tool_launch_service();
 457  
 458          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 459          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 460          $instructoruser = $this->getDataGenerator()->create_user();
 461          [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 462  
 463          // Delete the activity, then enable the enrolment method (it is disabled during activity deletion).
 464          $modcontext = \context::instance_by_id($resource->contextid);
 465          course_delete_module($modcontext->instanceid);
 466          $enrol = (object) ['id' => $resource->enrolid, 'status' => ENROL_INSTANCE_ENABLED];
 467          $DB->update_record('enrol', $enrol);
 468  
 469          $task = $this->get_task_with_mocked_grade_service();
 470          $this->expectOutputRegex(
 471              "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n".
 472              "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ".
 473              "course '$course->id'. Attempting to sync grades for all.\n".
 474              "Processing resource link '.*'.\n".
 475              "Failed - Invalid contextid '$resource->contextid' for the resource '$resource->id'.\n".
 476              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ".
 477              "sent 0 grades./"
 478          );
 479          $task->execute();
 480      }
 481  
 482      /**
 483       * Test grade sync when completion is required for the activity before sync takes place.
 484       *
 485       * @covers ::execute
 486       */
 487      public function test_sync_grades_completion_required() {
 488          $this->resetAfterTest();
 489          global $CFG;
 490          require_once($CFG->libdir . '/completionlib.php');
 491  
 492          [
 493              $course,
 494              $resource,
 495              $resource2,
 496              $publishedcourse
 497          ] = $this->create_test_environment(true, true, false, helper::MEMBER_SYNC_ENROL_AND_UNENROL, true, true);
 498          $launchservice = $this->get_tool_launch_service();
 499          $task = $this->get_task_with_mocked_grade_service();
 500  
 501          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 502          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 503          $instructoruser = $this->getDataGenerator()->create_user();
 504          [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 505  
 506          // Launch the resource for a few more users, creating those enrolments and allowing grading to take place.
 507          $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false,
 508              'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner');
 509          $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]);
 510          $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]);
 511          $student1user = $this->getDataGenerator()->create_user();
 512          $student2user = $this->getDataGenerator()->create_user();
 513          [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch);
 514          [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch);
 515  
 516          // Launch the published course as student2.
 517          $student2mockcourselaunch = $this->get_mock_launch($publishedcourse, $studentusers[1], '23456');
 518          $launchservice->user_launches_tool($student2user, $student2mockcourselaunch);
 519  
 520          // Grade student1 in the assign resource.
 521          $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource);
 522  
 523          // And student2 in the course resource.
 524          $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 55.5, $publishedcourse);
 525  
 526          // Sync and verify that no grades are sent because resource and published course are both not yet complete.
 527          ob_start();
 528          $task->execute();
 529          $ob = ob_get_contents();
 530          ob_end_clean();
 531          $expectedtraces = [
 532              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 533              "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ".
 534                  "'$course->id'.",
 535              "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ".
 536                  "'$course->id'.",
 537              "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ".
 538                  "'$course->id'.",
 539              "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ".
 540                  "the course '$course->id'.",
 541              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 542                  "Processed 3 users; sent 0 grades."
 543          ];
 544          foreach ($expectedtraces as $expectedtrace) {
 545              $this->assertStringContainsString($expectedtrace, $ob);
 546          }
 547  
 548          // Complete the resource for student1.
 549          $this->override_resource_completion_status_for_user($resource, $student1id, true);
 550  
 551          // Run the sync again, this time confirming the grade for student1 is sent.
 552          ob_start();
 553          $task->execute();
 554          $ob = ob_get_contents();
 555          ob_end_clean();
 556          $expectedtraces = [
 557              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 558              "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ".
 559                  "'$course->id'.",
 560              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 561                  "'$resource->id' and the course '$course->id' was sent.",
 562              "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ".
 563                  "'$course->id'.",
 564              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 565                  "Processed 3 users; sent 1 grades.",
 566              "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.",
 567              "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ".
 568                  "the course '$course->id'.",
 569              "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ".
 570                  "Processed 1 users; sent 0 grades.",
 571          ];
 572          foreach ($expectedtraces as $expectedtrace) {
 573              $this->assertStringContainsString($expectedtrace, $ob);
 574          }
 575  
 576          // Fail completion for student1 and confirm no grade is sent, even despite it being changed.
 577          $this->set_user_grade_for_resource($student1id, 33.3, $resource);
 578          $this->override_resource_completion_status_for_user($resource, $student1id, false);
 579  
 580          ob_start();
 581          $task->execute();
 582          $ob = ob_get_contents();
 583          ob_end_clean();
 584          $expectedtraces = [
 585              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 586              "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ".
 587                  "'$course->id'.",
 588              "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ".
 589                  "'$course->id'.",
 590              "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ".
 591                  "'$course->id'.",
 592              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 593                  "Processed 3 users; sent 0 grades.",
 594              "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.",
 595              "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ".
 596                  "the course '$course->id'.",
 597              "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ".
 598                  "Processed 1 users; sent 0 grades.",
 599          ];
 600          foreach ($expectedtraces as $expectedtrace) {
 601              $this->assertStringContainsString($expectedtrace, $ob);
 602          }
 603  
 604          // Complete the course for student2 and verify the grade is now sent.
 605          $this->override_resource_completion_status_for_user($publishedcourse, $student2id, true);
 606  
 607          ob_start();
 608          $task->execute();
 609          $ob = ob_get_contents();
 610          ob_end_clean();
 611          $expectedtraces = [
 612              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 613              "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ".
 614                  "'$course->id'.",
 615              "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ".
 616                  "'$course->id'.",
 617              "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ".
 618                  "'$course->id'.",
 619              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 620                  "Processed 3 users; sent 0 grades.",
 621              "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.",
 622              "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ".
 623                  "'$publishedcourse->id' and the course '$course->id' was sent.",
 624              "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ".
 625                  "Processed 1 users; sent 1 grades.",
 626  
 627          ];
 628          foreach ($expectedtraces as $expectedtrace) {
 629              $this->assertStringContainsString($expectedtrace, $ob);
 630          }
 631  
 632          // Mark the course as in progress again for student2 and verify any new grade changes are not sent.
 633          $this->set_user_grade_for_resource($student2id, 78.8, $publishedcourse);
 634          $this->override_resource_completion_status_for_user($publishedcourse, $student2id, false);
 635  
 636          ob_start();
 637          $task->execute();
 638          $ob = ob_get_contents();
 639          ob_end_clean();
 640          $expectedtraces = [
 641              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 642              "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ".
 643                  "'$course->id'.",
 644              "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ".
 645                  "'$course->id'.",
 646              "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ".
 647                  "'$course->id'.",
 648              "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ".
 649                  "the course '$course->id'.",
 650              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 651                  "Processed 3 users; sent 0 grades."
 652          ];
 653          foreach ($expectedtraces as $expectedtrace) {
 654              $this->assertStringContainsString($expectedtrace, $ob);
 655          }
 656      }
 657  
 658      /**
 659       * Test grade sync when the attempt to call the service returns an exception or a bad HTTP response code.
 660       *
 661       * @covers ::execute
 662       */
 663      public function test_sync_grades_failed_service_call() {
 664          $this->resetAfterTest();
 665          [$course, $resource] = $this->create_test_environment();
 666          $launchservice = $this->get_tool_launch_service();
 667          $task = $this->get_task_with_mocked_grade_service('200', true);
 668  
 669          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 670          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]);
 671          $instructoruser = $this->getDataGenerator()->create_user();
 672          [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 673  
 674          // Launch the resource for a student, creating the enrolment and allowing grading to take place.
 675          $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false,
 676              'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner');
 677          $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]);
 678          $student1user = $this->getDataGenerator()->create_user();
 679          [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch);
 680  
 681          // Grade student1 in the assign resource.
 682          $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource);
 683  
 684          // Run the sync, verifying that the response error causes a 'Failed' trace but that the task completes.
 685          ob_start();
 686          $task->execute();
 687          $ob = ob_get_contents();
 688          ob_end_clean();
 689          $expectedtraces = [
 690              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 691              "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 692                  "'$resource->id' and the course '$course->id' failed to send.",
 693              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 694                  "Processed 2 users; sent 0 grades."
 695          ];
 696          foreach ($expectedtraces as $expectedtrace) {
 697              $this->assertStringContainsString($expectedtrace, $ob);
 698          }
 699  
 700          // Now run the sync again, this time with a bad http response code.
 701          $task = $this->get_task_with_mocked_grade_service('400');
 702          ob_start();
 703          $task->execute();
 704          $ob = ob_get_contents();
 705          ob_end_clean();
 706          $expectedtraces = [
 707              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 708              "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 709              "'$resource->id' and the course '$course->id' failed to send.",
 710              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 711              "Processed 2 users; sent 0 grades."
 712          ];
 713          foreach ($expectedtraces as $expectedtrace) {
 714              $this->assertStringContainsString($expectedtrace, $ob);
 715          }
 716      }
 717  
 718      /**
 719       * Test the sync when only the lineitem URL is provided and when lineitem creation/query isn't expected.
 720       *
 721       * @covers ::execute
 722       */
 723      public function test_sync_grades_coupled_lineitem() {
 724          $this->resetAfterTest();
 725  
 726          [$course, $resource] = $this->create_test_environment();
 727          $launchservice = $this->get_tool_launch_service();
 728  
 729          // The launches use a coupled line item. Only scores can be posted. Line items and results cannot be created or queried.
 730          $agsclaim = [
 731              "scope" => ["https://purl.imsglobal.org/spec/lti-ags/scope/score"],
 732              "lineitem" => "https://platform.example.com/10/lineitems/45/lineitem"
 733          ];
 734  
 735          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 736          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], null,
 737              $agsclaim);
 738          $instructoruser = $this->getDataGenerator()->create_user();
 739          [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 740  
 741          // Launch the resource for a few more users, creating those enrolments and allowing grading to take place.
 742          $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false,
 743              'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner');
 744  
 745          $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0], null, $agsclaim);
 746          $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1], null, $agsclaim);
 747          $student1user = $this->getDataGenerator()->create_user();
 748          $student2user = $this->getDataGenerator()->create_user();
 749          [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch);
 750          [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch);
 751  
 752          // Grade student1 only.
 753          $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource);
 754  
 755          // Mock task, asserting that score posting to an existing line item takes place, via a mock grade service object.
 756          $mockgradeservice = $this->createMock(LtiAssignmentsGradesService::class);
 757          $mockgradeservice->method('putGrade')->willReturnCallback(function() {
 758              return ['headers' => ['httpstatus' => "HTTP/2 200 OK"], 'body' => '', 'status' => 200];
 759          });
 760          $mockgradeservice->expects($this->never())
 761              ->method('findOrCreateLineitem');
 762          $mockgradeservice->expects($this->once())
 763              ->method('putGrade')
 764              ->with($this->isInstanceOf(LtiGrade::class));
 765          $mocktask = $this->getMockBuilder(sync_grades::class)
 766              ->onlyMethods(['get_ags'])
 767              ->getMock();
 768          $mocktask->method('get_ags')->willReturn($mockgradeservice);
 769  
 770          // Sync and verify that only student1's grade is sent.
 771          ob_start();
 772          $mocktask->execute();
 773          $ob = ob_get_contents();
 774          ob_end_clean();
 775          $expectedtraces = [
 776              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 777              "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ".
 778              "'$course->id'.",
 779              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 780              "'$resource->id' and the course '$course->id' was sent.",
 781              "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ".
 782              "'$course->id'.",
 783              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 784              "Processed 3 users; sent 1 grades."
 785          ];
 786          foreach ($expectedtraces as $expectedtrace) {
 787              $this->assertStringContainsString($expectedtrace, $ob);
 788          }
 789      }
 790  
 791      /**
 792       * Test the sync when only the lineitems URL is provided and when line item creation/query is expected.
 793       *
 794       * @covers ::execute
 795       */
 796      public function test_sync_grades_none_or_many_lineitems() {
 797          $this->resetAfterTest();
 798  
 799          [$course, $resource] = $this->create_test_environment();
 800          $launchservice = $this->get_tool_launch_service();
 801  
 802          // The launches omit the 'lineitem' claim, meaning the item may have none (or many) line items.
 803          $agsclaim = [
 804              "scope" => [
 805                  "https://purl.imsglobal.org/spec/lti-ags/scope/score",
 806                  "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
 807              ],
 808              "lineitems" => "https://platform.example.com/10/lineitems"
 809          ];
 810  
 811          // Launch the resource for an instructor which will create the domain objects needed for service calls.
 812          $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], null,
 813              $agsclaim);
 814          $instructoruser = $this->getDataGenerator()->create_user();
 815          [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch);
 816  
 817          // Launch the resource for a few more users, creating those enrolments and allowing grading to take place.
 818          $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false,
 819              'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner');
 820  
 821          $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0], null, $agsclaim);
 822          $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1], null, $agsclaim);
 823          $student1user = $this->getDataGenerator()->create_user();
 824          $student2user = $this->getDataGenerator()->create_user();
 825          [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch);
 826          [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch);
 827  
 828          // Grade student1 only.
 829          $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource);
 830  
 831          // Mock task, asserting that line item creation takes place via a mock grade service object.
 832          $mockgradeservice = $this->createMock(LtiAssignmentsGradesService::class);
 833          $mockgradeservice->method('putGrade')->willReturnCallback(function() {
 834              return ['headers' => ['httpstatus' => "HTTP/2 200 OK"], 'body' => '', 'status' => 200];
 835          });
 836          $mockgradeservice->expects($this->once())
 837              ->method('findOrCreateLineitem');
 838          $mockgradeservice->expects($this->once())
 839              ->method('putGrade')
 840              ->with($this->isInstanceOf(LtiGrade::class), $this->isInstanceOf(LtiLineitem::class));
 841          $mocktask = $this->getMockBuilder(sync_grades::class)
 842              ->onlyMethods(['get_ags'])
 843              ->getMock();
 844          $mocktask->method('get_ags')->willReturn($mockgradeservice);
 845  
 846          // Sync and verify that only student1's grade is sent.
 847          ob_start();
 848          $mocktask->execute();
 849          $ob = ob_get_contents();
 850          ob_end_clean();
 851          $expectedtraces = [
 852              "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.",
 853              "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ".
 854              "'$course->id'.",
 855              "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ".
 856              "'$resource->id' and the course '$course->id' was sent.",
 857              "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ".
 858              "'$course->id'.",
 859              "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ".
 860              "Processed 3 users; sent 1 grades."
 861          ];
 862          foreach ($expectedtraces as $expectedtrace) {
 863              $this->assertStringContainsString($expectedtrace, $ob);
 864          }
 865      }
 866  }