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