diff --git a/enrol/lti/classes/local/ltiadvantage/task/sync_grades.php b/enrol/lti/classes/local/ltiadvantage/task/sync_grades.php index f9d3735ed1f..71af42f35cd 100644 --- a/enrol/lti/classes/local/ltiadvantage/task/sync_grades.php +++ b/enrol/lti/classes/local/ltiadvantage/task/sync_grades.php @@ -18,18 +18,6 @@ namespace enrol_lti\local\ltiadvantage\task; use core\task\scheduled_task; use enrol_lti\helper; -use enrol_lti\local\ltiadvantage\lib\http_client; -use enrol_lti\local\ltiadvantage\lib\issuer_database; -use enrol_lti\local\ltiadvantage\lib\launch_cache_session; -use enrol_lti\local\ltiadvantage\repository\application_registration_repository; -use enrol_lti\local\ltiadvantage\repository\deployment_repository; -use enrol_lti\local\ltiadvantage\repository\resource_link_repository; -use enrol_lti\local\ltiadvantage\repository\user_repository; -use Packback\Lti1p3\LtiAssignmentsGradesService; -use Packback\Lti1p3\LtiGrade; -use Packback\Lti1p3\LtiLineitem; -use Packback\Lti1p3\LtiRegistration; -use Packback\Lti1p3\LtiServiceConnector; /** * LTI Advantage task responsible for pushing grades to tool platforms. @@ -50,224 +38,11 @@ class sync_grades extends scheduled_task { } /** - * Sync grades to the platform using the Assignment and Grade Services. - * - * @param \stdClass $resource the enrol_lti_tools data record for the shared resource. - * @return array an array containing the - */ - protected function sync_grades_for_resource($resource): array { - $usercount = 0; - $sendcount = 0; - $userrepo = new user_repository(); - $resourcelinkrepo = new resource_link_repository(); - $appregistrationrepo = new application_registration_repository(); - $issuerdb = new issuer_database($appregistrationrepo, new deployment_repository()); - - if ($users = $userrepo->find_by_resource($resource->id)) { - $completion = new \completion_info(get_course($resource->courseid)); - $syncedusergrades = []; // Keep track of those users who have had their grade synced during this run. - foreach ($users as $user) { - $mtracecontent = "for the user '{$user->get_localid()}', for the resource '$resource->id' and the course " . - "'$resource->courseid'"; - $usercount++; - - // Check if we do not have a grade service endpoint in either of the resource links. - // Remember, not all launches need to support grade services. - $userresourcelinks = $resourcelinkrepo->find_by_resource_and_user($resource->id, $user->get_id()); - $userlastgrade = $user->get_lastgrade(); - mtrace("Found ".count($userresourcelinks)." resource link(s) $mtracecontent. Attempting to sync grades for all."); - - foreach ($userresourcelinks as $userresourcelink) { - mtrace("Processing resource link '{$userresourcelink->get_resourcelinkid()}'."); - if (!$gradeservice = $userresourcelink->get_grade_service()) { - mtrace("Skipping - No grade service found $mtracecontent."); - continue; - } - - if (!$context = \context::instance_by_id($resource->contextid, IGNORE_MISSING)) { - mtrace("Failed - Invalid contextid '$resource->contextid' for the resource '$resource->id'."); - continue; - } - - $grade = false; - $dategraded = false; - if ($context->contextlevel == CONTEXT_COURSE) { - if ($resource->gradesynccompletion && !$completion->is_course_complete($user->get_localid())) { - mtrace("Skipping - Course not completed $mtracecontent."); - continue; - } - - // Get the grade. - if ($grade = grade_get_course_grade($user->get_localid(), $resource->courseid)) { - $grademax = floatval($grade->item->grademax); - $dategraded = $grade->dategraded; - $grade = $grade->grade; - } - } else if ($context->contextlevel == CONTEXT_MODULE) { - $cm = get_coursemodule_from_id(false, $context->instanceid, 0, false, MUST_EXIST); - - if ($resource->gradesynccompletion) { - $data = $completion->get_data($cm, false, $user->get_localid()); - if (!in_array($data->completionstate, [COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE])) { - mtrace("Skipping - Activity not completed $mtracecontent."); - continue; - } - } - - $grades = grade_get_grades($cm->course, 'mod', $cm->modname, $cm->instance, - $user->get_localid()); - if (!empty($grades->items[0]->grades)) { - $grade = reset($grades->items[0]->grades); - if (!empty($grade->item)) { - $grademax = floatval($grade->item->grademax); - } else { - $grademax = floatval($grades->items[0]->grademax); - } - $dategraded = $grade->dategraded; - $grade = $grade->grade; - } - } - - if ($grade === false || $grade === null || strlen($grade) < 1) { - mtrace("Skipping - Invalid grade $mtracecontent."); - continue; - } - - if (empty($grademax)) { - mtrace("Skipping - Invalid grademax $mtracecontent."); - continue; - } - - if (!grade_floats_different($grade, $userlastgrade)) { - mtrace("Not sent - The grade $mtracecontent was not sent as the grades are the same."); - continue; - } - $floatgrade = $grade / $grademax; - - try { - // Get an AGS instance for the corresponding application registration and service data. - $appregistration = $appregistrationrepo->find_by_deployment( - $userresourcelink->get_deploymentid() - ); - $registration = $issuerdb->findRegistrationByIssuer( - $appregistration->get_platformid()->out(false), - $appregistration->get_clientid() - ); - global $CFG; - require_once($CFG->libdir . '/filelib.php'); - $sc = new LtiServiceConnector(new launch_cache_session(), new http_client(new \curl())); - - $lineitemurl = $gradeservice->get_lineitemurl(); - $lineitemsurl = $gradeservice->get_lineitemsurl(); - $servicedata = [ - 'lineitems' => $lineitemsurl ? $lineitemsurl->out(false) : null, - 'lineitem' => $lineitemurl ? $lineitemurl->out(false) : null, - 'scope' => $gradeservice->get_scopes(), - ]; - - $ags = $this->get_ags($sc, $registration, $servicedata); - $ltigrade = LtiGrade::new() - ->setScoreGiven($grade) - ->setScoreMaximum($grademax) - ->setUserId($user->get_sourceid()) - ->setTimestamp(date(\DateTimeInterface::ISO8601, $dategraded)) - ->setActivityProgress('Completed') - ->setGradingProgress('FullyGraded'); - - if (empty($servicedata['lineitem'])) { - // The launch did not include a couple lineitem, so find or create the line item for grading. - $lineitem = $ags->findOrCreateLineitem(new LtiLineitem([ - 'label' => $this->get_line_item_label($resource, $context), - 'scoreMaximum' => $grademax, - 'tag' => 'grade', - 'resourceId' => $userresourcelink->get_resourceid(), - 'resourceLinkId' => $userresourcelink->get_resourcelinkid() - ])); - $response = $ags->putGrade($ltigrade, $lineitem); - } else { - // Let AGS find the coupled line item. - $response = $ags->putGrade($ltigrade); - } - - } catch (\Exception $e) { - mtrace("Failed - The grade '$floatgrade' $mtracecontent failed to send."); - mtrace($e->getMessage()); - continue; - } - - if ($response['status'] == 200) { - $user->set_lastgrade(grade_floatval($grade)); - $syncedusergrades[$user->get_id()] = $user; - mtrace("Success - The grade '$floatgrade' $mtracecontent was sent."); - } else { - mtrace("Failed - The grade '$floatgrade' $mtracecontent failed to send."); - mtrace("Header: {$response['headers']['httpstatus']}"); - } - } - } - // Update the lastgrade value for any users who had a grade synced. Allows skipping on future runs if not changed. - // Update the count of total users having their grades synced, not the total number of grade sync calls made. - foreach ($syncedusergrades as $ltiuser) { - $userrepo->save($ltiuser); - $sendcount = $sendcount + 1; - } - } - return [$usercount, $sendcount]; - } - - /** - * Get the string label for the line item associated with the resource, based on the course or module name. - * - * @param \stdClass $resource the enrol_lti_tools record. - * @param \context $context the context of the resource - either course or module. - * @return string the label to use in the line item. - */ - protected function get_line_item_label(\stdClass $resource, \context $context): string { - $resourcename = 'default'; - if ($context->contextlevel == CONTEXT_COURSE) { - global $DB; - $coursenamesql = "SELECT c.fullname - FROM {enrol_lti_tools} t - JOIN {enrol} e - ON (e.id = t.enrolid) - JOIN {course} c - ON {c.id} = e.courseid - WHERE t.id = :resourceid"; - $coursename = $DB->get_field_sql($coursenamesql, ['resourceid' => $resource->id]); - $resourcename = format_string($coursename, true, ['context' => $context->id]); - } else if ($context->contextlevel == CONTEXT_MODULE) { - foreach (get_fast_modinfo($resource->courseid)->get_cms() as $mod) { - if ($mod->context->id == $context->id) { - $resourcename = $mod->name; - } - } - } - return $resourcename; - } - - /** - * Get an ags instance to make the call to the platform. - * - * @param LtiServiceConnector $sc a service connector instance. - * @param LtiRegistration $registration the registration instance. - * @param array $sd the service data. - * @return LtiAssignmentsGradesService - */ - protected function get_ags(LtiServiceConnector $sc, LtiRegistration $registration, array $sd): LtiAssignmentsGradesService { - return new LtiAssignmentsGradesService($sc, $registration, $sd); - } - - /** - * Performs the synchronisation of grades from the tool to any registered platforms. + * Creates adhoc tasks (one per resource) to synchronize grades from the tool to any registered platforms. * * @return bool|void */ public function execute() { - global $CFG; - - require_once($CFG->dirroot . '/lib/completionlib.php'); - require_once($CFG->libdir . '/gradelib.php'); - require_once($CFG->dirroot . '/grade/querylib.php'); if (!is_enabled_auth('lti')) { mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti'))); @@ -289,13 +64,12 @@ class sync_grades extends scheduled_task { } foreach ($resources as $resource) { - mtrace("Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$resource->courseid'."); - - [$usercount, $sendcount] = $this->sync_grades_for_resource($resource); - - mtrace("Completed - Synced grades for tool '$resource->id' in the course '$resource->courseid'. " . - "Processed $usercount users; sent $sendcount grades."); - mtrace(""); + $task = new \enrol_lti\local\ltiadvantage\task\sync_tool_grades(); + $task->set_custom_data($resource); + $task->set_component('enrol_lti'); + \core\task\manager::queue_adhoc_task($task, true); } + + mtrace('Spawned ' . count($resources) . ' adhoc tasks to sync grades.'); } } diff --git a/enrol/lti/classes/local/ltiadvantage/task/sync_tool_grades.php b/enrol/lti/classes/local/ltiadvantage/task/sync_tool_grades.php new file mode 100644 index 00000000000..6759f228110 --- /dev/null +++ b/enrol/lti/classes/local/ltiadvantage/task/sync_tool_grades.php @@ -0,0 +1,273 @@ +. + +namespace enrol_lti\local\ltiadvantage\task; + +use core\task\adhoc_task; +use enrol_lti\local\ltiadvantage\lib\http_client; +use enrol_lti\local\ltiadvantage\lib\issuer_database; +use enrol_lti\local\ltiadvantage\lib\launch_cache_session; +use enrol_lti\local\ltiadvantage\repository\application_registration_repository; +use enrol_lti\local\ltiadvantage\repository\deployment_repository; +use enrol_lti\local\ltiadvantage\repository\resource_link_repository; +use enrol_lti\local\ltiadvantage\repository\user_repository; +use Packback\Lti1p3\LtiAssignmentsGradesService; +use Packback\Lti1p3\LtiGrade; +use Packback\Lti1p3\LtiLineitem; +use Packback\Lti1p3\LtiRegistration; +use Packback\Lti1p3\LtiServiceConnector; + +/** + * LTI Advantage task responsible for pushing grades to tool platforms. + * + * @package enrol_lti + * @copyright 2023 David Pesce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sync_tool_grades extends adhoc_task { + + /** + * Sync grades to the platform using the Assignment and Grade Services (AGS). + * + * @param \stdClass $resource the enrol_lti_tools data record for the shared resource. + * @return array an array containing the + */ + protected function sync_grades_for_resource($resource): array { + $usercount = 0; + $sendcount = 0; + $userrepo = new user_repository(); + $resourcelinkrepo = new resource_link_repository(); + $appregistrationrepo = new application_registration_repository(); + $issuerdb = new issuer_database($appregistrationrepo, new deployment_repository()); + + if ($users = $userrepo->find_by_resource($resource->id)) { + $completion = new \completion_info(get_course($resource->courseid)); + $syncedusergrades = []; // Keep track of those users who have had their grade synced during this run. + foreach ($users as $user) { + $mtracecontent = "for the user '{$user->get_localid()}', for the resource '$resource->id' and the course " . + "'$resource->courseid'"; + $usercount++; + + // Check if we do not have a grade service endpoint in either of the resource links. + // Remember, not all launches need to support grade services. + $userresourcelinks = $resourcelinkrepo->find_by_resource_and_user($resource->id, $user->get_id()); + $userlastgrade = $user->get_lastgrade(); + mtrace("Found ".count($userresourcelinks)." resource link(s) $mtracecontent. Attempting to sync grades for all."); + + foreach ($userresourcelinks as $userresourcelink) { + mtrace("Processing resource link '{$userresourcelink->get_resourcelinkid()}'."); + if (!$gradeservice = $userresourcelink->get_grade_service()) { + mtrace("Skipping - No grade service found $mtracecontent."); + continue; + } + + if (!$context = \context::instance_by_id($resource->contextid, IGNORE_MISSING)) { + mtrace("Failed - Invalid contextid '$resource->contextid' for the resource '$resource->id'."); + continue; + } + + $grade = false; + $dategraded = false; + if ($context->contextlevel == CONTEXT_COURSE) { + if ($resource->gradesynccompletion && !$completion->is_course_complete($user->get_localid())) { + mtrace("Skipping - Course not completed $mtracecontent."); + continue; + } + + // Get the grade. + if ($grade = grade_get_course_grade($user->get_localid(), $resource->courseid)) { + $grademax = floatval($grade->item->grademax); + $dategraded = $grade->dategraded; + $grade = $grade->grade; + } + } else if ($context->contextlevel == CONTEXT_MODULE) { + $cm = get_coursemodule_from_id(false, $context->instanceid, 0, false, MUST_EXIST); + + if ($resource->gradesynccompletion) { + $data = $completion->get_data($cm, false, $user->get_localid()); + if (!in_array($data->completionstate, [COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE])) { + mtrace("Skipping - Activity not completed $mtracecontent."); + continue; + } + } + + $grades = grade_get_grades($cm->course, 'mod', $cm->modname, $cm->instance, + $user->get_localid()); + if (!empty($grades->items[0]->grades)) { + $grade = reset($grades->items[0]->grades); + if (!empty($grade->item)) { + $grademax = floatval($grade->item->grademax); + } else { + $grademax = floatval($grades->items[0]->grademax); + } + $dategraded = $grade->dategraded; + $grade = $grade->grade; + } + } + + if ($grade === false || $grade === null || strlen($grade) < 1) { + mtrace("Skipping - Invalid grade $mtracecontent."); + continue; + } + + if (empty($grademax)) { + mtrace("Skipping - Invalid grademax $mtracecontent."); + continue; + } + + if (!grade_floats_different($grade, $userlastgrade)) { + mtrace("Not sent - The grade $mtracecontent was not sent as the grades are the same."); + continue; + } + $floatgrade = $grade / $grademax; + + try { + // Get an AGS instance for the corresponding application registration and service data. + $appregistration = $appregistrationrepo->find_by_deployment( + $userresourcelink->get_deploymentid() + ); + $registration = $issuerdb->findRegistrationByIssuer( + $appregistration->get_platformid()->out(false), + $appregistration->get_clientid() + ); + global $CFG; + require_once($CFG->libdir . '/filelib.php'); + $sc = new LtiServiceConnector(new launch_cache_session(), new http_client(new \curl())); + + $lineitemurl = $gradeservice->get_lineitemurl(); + $lineitemsurl = $gradeservice->get_lineitemsurl(); + $servicedata = [ + 'lineitems' => $lineitemsurl ? $lineitemsurl->out(false) : null, + 'lineitem' => $lineitemurl ? $lineitemurl->out(false) : null, + 'scope' => $gradeservice->get_scopes(), + ]; + + $ags = $this->get_ags($sc, $registration, $servicedata); + $ltigrade = LtiGrade::new() + ->setScoreGiven($grade) + ->setScoreMaximum($grademax) + ->setUserId($user->get_sourceid()) + ->setTimestamp(date(\DateTimeInterface::ISO8601, $dategraded)) + ->setActivityProgress('Completed') + ->setGradingProgress('FullyGraded'); + + if (empty($servicedata['lineitem'])) { + // The launch did not include a couple lineitem, so find or create the line item for grading. + $lineitem = $ags->findOrCreateLineitem(new LtiLineitem([ + 'label' => $this->get_line_item_label($resource, $context), + 'scoreMaximum' => $grademax, + 'tag' => 'grade', + 'resourceId' => $userresourcelink->get_resourceid(), + 'resourceLinkId' => $userresourcelink->get_resourcelinkid() + ])); + $response = $ags->putGrade($ltigrade, $lineitem); + } else { + // Let AGS find the coupled line item. + $response = $ags->putGrade($ltigrade); + } + + } catch (\Exception $e) { + mtrace("Failed - The grade '$floatgrade' $mtracecontent failed to send."); + mtrace($e->getMessage()); + continue; + } + + if ($response['status'] == 200) { + $user->set_lastgrade(grade_floatval($grade)); + $syncedusergrades[$user->get_id()] = $user; + mtrace("Success - The grade '$floatgrade' $mtracecontent was sent."); + } else { + mtrace("Failed - The grade '$floatgrade' $mtracecontent failed to send."); + mtrace("Header: {$response['headers']['httpstatus']}"); + } + } + } + // Update the lastgrade value for any users who had a grade synced. Allows skipping on future runs if not changed. + // Update the count of total users having their grades synced, not the total number of grade sync calls made. + foreach ($syncedusergrades as $ltiuser) { + $userrepo->save($ltiuser); + $sendcount = $sendcount + 1; + } + } + return [$usercount, $sendcount]; + } + + /** + * Get the string label for the line item associated with the resource, based on the course or module name. + * + * @param \stdClass $resource the enrol_lti_tools record. + * @param \context $context the context of the resource - either course or module. + * @return string the label to use in the line item. + */ + protected function get_line_item_label(\stdClass $resource, \context $context): string { + $resourcename = 'default'; + if ($context->contextlevel == CONTEXT_COURSE) { + global $DB; + $coursenamesql = "SELECT c.fullname + FROM {enrol_lti_tools} t + JOIN {enrol} e + ON (e.id = t.enrolid) + JOIN {course} c + ON {c.id} = e.courseid + WHERE t.id = :resourceid"; + $coursename = $DB->get_field_sql($coursenamesql, ['resourceid' => $resource->id]); + $resourcename = format_string($coursename, true, ['context' => $context->id]); + } else if ($context->contextlevel == CONTEXT_MODULE) { + foreach (get_fast_modinfo($resource->courseid)->get_cms() as $mod) { + if ($mod->context->id == $context->id) { + $resourcename = $mod->name; + } + } + } + return $resourcename; + } + + /** + * Get an Assignment and Grade Services (AGS) instance to make the call to the platform. + * + * @param LtiServiceConnector $sc a service connector instance. + * @param LtiRegistration $registration the registration instance. + * @param array $sd the service data. + * @return LtiAssignmentsGradesService + */ + protected function get_ags(LtiServiceConnector $sc, LtiRegistration $registration, array $sd): LtiAssignmentsGradesService { + return new LtiAssignmentsGradesService($sc, $registration, $sd); + } + + /** + * Performs the synchronisation of grades from the tool to any registered platforms. + * + * @return bool|void + */ + public function execute() { + global $CFG; + + require_once($CFG->dirroot . '/lib/completionlib.php'); + require_once($CFG->libdir . '/gradelib.php'); + require_once($CFG->dirroot . '/grade/querylib.php'); + + $resource = $this->get_custom_data(); + + mtrace("Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$resource->courseid'."); + + [$usercount, $sendcount] = $this->sync_grades_for_resource($resource); + + mtrace("Completed - Synced grades for tool '$resource->id' in the course '$resource->courseid'. " . + "Processed $usercount users; sent $sendcount grades."); + mtrace(""); + + } +} diff --git a/enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php b/enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php index 28c27661d21..7040f10b6d9 100644 --- a/enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php +++ b/enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php @@ -34,115 +34,6 @@ require_once(__DIR__ . '/../lti_advantage_testcase.php'); * @coversDefaultClass \enrol_lti\local\ltiadvantage\task\sync_grades */ class sync_grades_test extends \lti_advantage_testcase { - /** - * Get a task which has a mocked ags instance injected, meaning no real calls will be made to the platform. - * - * This allows us to test the behaviour of the task (in terms of which users are in scope and which grades are sent) - * without needing to deal with any auth. - * - * @param string $statuscode the HTTP status code to simulate. - * @param bool $mockexception whether to simulate an exception during the service call or not. - * @return sync_grades instance of the task with a mocked ags instance inside. - */ - protected function get_task_with_mocked_grade_service($statuscode = '200', $mockexception = false): sync_grades { - $mockgradeservice = $this->getMockBuilder(LtiAssignmentsGradesService::class) - ->disableOriginalConstructor() - ->onlyMethods(['putGrade']) - ->getMock(); - $mockgradeservice->method('putGrade')->willReturnCallback(function() use ($statuscode, $mockexception) { - if ($mockexception) { - throw new \Exception(); - } - return ['headers' => ['httpstatus' => "HTTP/2 $statuscode OK"], 'body' => '', 'status' => $statuscode]; - }); - // Get a mock task, with the method 'get_ags()' mocked to return the mocked AGS instance. - $mocktask = $this->getMockBuilder(sync_grades::class) - ->onlyMethods(['get_ags']) - ->getMock(); - $mocktask->method('get_ags')->willReturn($mockgradeservice); - return $mocktask; - } - - /** - * Helper function to set a grade for a user. - * - * @param int $userid the id of the user being graded. - * @param float $grade the grade value, out of 100, to set for the user. - * @param \stdClass $resource the published resource object. - * @return float the fractional grade value expected to be used during sync. - */ - protected function set_user_grade_for_resource(int $userid, float $grade, \stdClass $resource): float { - - global $CFG; - require_once($CFG->libdir . '/accesslib.php'); - require_once($CFG->libdir . '/gradelib.php'); - $context = \context::instance_by_id($resource->contextid); - - if ($context->contextlevel == CONTEXT_COURSE) { - $gi = \grade_item::fetch_course_item($resource->courseid); - } else if ($context->contextlevel == CONTEXT_MODULE) { - $cm = get_coursemodule_from_id('assign', $context->instanceid); - - $gi = \grade_item::fetch([ - 'itemtype' => 'mod', - 'itemmodule' => 'assign', - 'iteminstance' => $cm->instance, - 'courseid' => $resource->courseid - ]); - } - - if ($ggrade = \grade_grade::fetch(['itemid' => $gi->id, 'userid' => $userid])) { - $ggrade->finalgrade = $grade; - $ggrade->rawgrade = $grade; - $ggrade->update(); - } else { - $ggrade = new \grade_grade(); - $ggrade->itemid = $gi->id; - $ggrade->userid = $userid; - $ggrade->rawgrade = $grade; - $ggrade->finalgrade = $grade; - $ggrade->rawgrademax = 100; - $ggrade->rawgrademin = 0; - $ggrade->timecreated = time(); - $ggrade->timemodified = time(); - $ggrade->insert(); - } - return floatval($ggrade->finalgrade / $gi->grademax); - } - - /** - * Helper to set the completion status for published course or module. - * - * @param \stdClass $resource the resource - either a course or module. - * @param int $userid the id of the user to override the completion status for. - * @param bool $complete whether the resource is deemed complete or not. - */ - protected function override_resource_completion_status_for_user(\stdClass $resource, int $userid, - bool $complete): void { - - global $CFG; - require_once($CFG->libdir . '/accesslib.php'); - require_once($CFG->libdir . '/completionlib.php'); - require_once($CFG->libdir . '/datalib.php'); - $this->setAdminUser(); - $context = \context::instance_by_id($resource->contextid); - $completion = new \completion_info(get_course($resource->courseid)); - if ($context->contextlevel == CONTEXT_COURSE) { - $ccompletion = new \completion_completion(['userid' => $userid, 'course' => $resource->courseid]); - if ($complete) { - $ccompletion->mark_complete(); - } else { - $completion->clear_criteria(); - } - } else if ($context->contextlevel == CONTEXT_MODULE) { - $completion->update_state( - get_coursemodule_from_id('assign', $context->instanceid), - $complete ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE, - $userid, - true - ); - } - } /** * Test confirming task name. @@ -153,175 +44,6 @@ class sync_grades_test extends \lti_advantage_testcase { $this->assertEquals(get_string('tasksyncgrades', 'enrol_lti'), (new sync_grades())->get_name()); } - /** - * Test the sync grades task during several runs and for a series of grade changes. - * - * @covers ::execute - */ - public function test_grade_sync_chronological_syncs() { - $this->resetAfterTest(); - - [$course, $resource] = $this->create_test_environment(); - $launchservice = $this->get_tool_launch_service(); - $task = $this->get_task_with_mocked_grade_service(); - - // Launch the resource for an instructor which will create the domain objects needed for service calls. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); - $instructoruser = $this->getDataGenerator()->create_user(); - [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. - $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, - 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); - - $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); - $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]); - $student1user = $this->getDataGenerator()->create_user(); - $student2user = $this->getDataGenerator()->create_user(); - [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); - [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); - - // Grade student1 only. - $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); - - // Sync and verify that only student1's grade is sent. - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.", - "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 1 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Sync again, verifying no grades are sent because nothing has changed. - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Not sent - The grade for the user '$student1id', for the resource '$resource->id' and the course ". - "'$course->id' was not sent as the grades are the same.", - "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 0 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Change student1's grade and add a grade for student2. - $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 68.5, $resource); - $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 44.5, $resource); - - // Sync again, verifying both grade changes are sent. - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.", - "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 2 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - } - - /** - * Test a grade sync when there are more than one resource link for the resource. - * - * @covers ::execute - */ - public function test_grade_sync_multiple_resource_links() { - $this->resetAfterTest(); - - [$course, $resource] = $this->create_test_environment(); - $launchservice = $this->get_tool_launch_service(); - $task = $this->get_task_with_mocked_grade_service(); - - // Launch the resource first for an instructor using the default resource link in the platform. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); - $instructoruser = $this->getDataGenerator()->create_user(); - [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - // Launch again as the instructor, this time from a different resource link in the platform. - $teachermocklaunch2 = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], 'RLID-2'); - $launchservice->user_launches_tool($instructoruser, $teachermocklaunch2); - - // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. - $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, - 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); - - $student1reslink1launch = $this->get_mock_launch($resource, $studentusers[0]); - $student2reslink1launch = $this->get_mock_launch($resource, $studentusers[1]); - $student1reslink2launch = $this->get_mock_launch($resource, $studentusers[1], 'RLID-2'); - $student1user = $this->getDataGenerator()->create_user(); - $student2user = $this->getDataGenerator()->create_user(); - [$student1id] = $launchservice->user_launches_tool($student1user, $student1reslink1launch); - [$student2id] = $launchservice->user_launches_tool($student2user, $student2reslink1launch); - $launchservice->user_launches_tool($student1user, $student1reslink2launch); - - // Grade student1 only. - $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); - - // Sync and verify that only student1's grade is sent but that it's sent for BOTH resource links. - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.", - "Found 2 resource link(s) for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.", - "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 1 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Verify that the grade was reported as being synced twice - once for each resource link. - $expected = "/Found 2 resource link\(s\) for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.\n". - "Processing resource link '.*'.\n". - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.\n". - "Processing resource link '.*'.\n". - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent./"; - $this->assertMatchesRegularExpression($expected, $ob); - } - /** * Test grade sync when the resource has syncgrades disabled. * @@ -338,7 +60,7 @@ class sync_grades_test extends \lti_advantage_testcase { $instructoruser = $this->getDataGenerator()->create_user(); $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - $task = $this->get_task_with_mocked_grade_service(); + $task = new \enrol_lti\local\ltiadvantage\task\sync_grades(); $this->expectOutputRegex('/Skipping task - There are no resources with grade sync enabled./'); $task->execute(); } @@ -358,7 +80,7 @@ class sync_grades_test extends \lti_advantage_testcase { $instructoruser = $this->getDataGenerator()->create_user(); $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - $task = $this->get_task_with_mocked_grade_service(); + $task = new \enrol_lti\local\ltiadvantage\task\sync_grades(); $this->expectOutputRegex('/Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti')) . '/'); $task->execute(); @@ -379,488 +101,8 @@ class sync_grades_test extends \lti_advantage_testcase { $instructoruser = $this->getDataGenerator()->create_user(); $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - $task = $this->get_task_with_mocked_grade_service(); + $task = new \enrol_lti\local\ltiadvantage\task\sync_grades(); $this->expectOutputRegex('/Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti') . '/'); $task->execute(); } - - /** - * Test the grade sync task when the launch data doesn't include the AGS support. - * - * @covers ::execute - */ - public function test_sync_grades_no_service_endpoint() { - $this->resetAfterTest(); - [$course, $resource] = $this->create_test_environment(); - $launchservice = $this->get_tool_launch_service(); - - // Launch the resource for an instructor which will create the domain objects needed for service calls. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], - null, null); - $instructoruser = $this->getDataGenerator()->create_user(); - [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - $task = $this->get_task_with_mocked_grade_service(); - $this->expectOutputRegex( - "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n". - "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ". - "course '$course->id'. Attempting to sync grades for all.\n". - "Processing resource link '.*'.\n". - "Skipping - No grade service found for the user '$userid', for the resource '$resource->id' and the ". - "course '$course->id'.\n". - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ". - "sent 0 grades./" - ); - $task->execute(); - } - - /** - * Test syncing grades when the enrolment instance is disabled. - * - * @covers ::execute - */ - public function test_sync_grades_disabled_instance() { - $this->resetAfterTest(); - global $DB; - - [$course, $resource, $resource2, $resource3] = $this->create_test_environment(); - - // Disable resource 1. - $enrol = (object) ['id' => $resource->enrolid, 'status' => ENROL_INSTANCE_DISABLED]; - $DB->update_record('enrol', $enrol); - - // Delete the activity being shared by resource 2, leaving resource 2 disabled as a result. - $modcontext = \context::instance_by_id($resource2->contextid); - course_delete_module($modcontext->instanceid); - - // Only the enabled resource 3 should sync grades. - $task = $this->get_task_with_mocked_grade_service(); - $this->expectOutputRegex( - "/^Starting - LTI Advantage grade sync for shared resource '$resource3->id' in course '$course->id'.\n". - "Completed - Synced grades for tool '$resource3->id' in the course '$course->id'. Processed 0 users; ". - "sent 0 grades.\n$/" - ); - $task->execute(); - } - - /** - * Test the grade sync when the context has been deleted in between launch and when the grade sync task is run. - * - * @covers ::execute - */ - public function test_sync_grades_deleted_context() { - $this->resetAfterTest(); - global $DB; - - [$course, $resource] = $this->create_test_environment(); - $launchservice = $this->get_tool_launch_service(); - - // Launch the resource for an instructor which will create the domain objects needed for service calls. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); - $instructoruser = $this->getDataGenerator()->create_user(); - [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - // Delete the activity, then enable the enrolment method (it is disabled during activity deletion). - $modcontext = \context::instance_by_id($resource->contextid); - course_delete_module($modcontext->instanceid); - $enrol = (object) ['id' => $resource->enrolid, 'status' => ENROL_INSTANCE_ENABLED]; - $DB->update_record('enrol', $enrol); - - $task = $this->get_task_with_mocked_grade_service(); - $this->expectOutputRegex( - "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n". - "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ". - "course '$course->id'. Attempting to sync grades for all.\n". - "Processing resource link '.*'.\n". - "Failed - Invalid contextid '$resource->contextid' for the resource '$resource->id'.\n". - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ". - "sent 0 grades./" - ); - $task->execute(); - } - - /** - * Test grade sync when completion is required for the activity before sync takes place. - * - * @covers ::execute - */ - public function test_sync_grades_completion_required() { - $this->resetAfterTest(); - global $CFG; - require_once($CFG->libdir . '/completionlib.php'); - - [ - $course, - $resource, - $resource2, - $publishedcourse - ] = $this->create_test_environment(true, true, false, helper::MEMBER_SYNC_ENROL_AND_UNENROL, true, true); - $launchservice = $this->get_tool_launch_service(); - $task = $this->get_task_with_mocked_grade_service(); - - // Launch the resource for an instructor which will create the domain objects needed for service calls. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); - $instructoruser = $this->getDataGenerator()->create_user(); - [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. - $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, - 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); - $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); - $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]); - $student1user = $this->getDataGenerator()->create_user(); - $student2user = $this->getDataGenerator()->create_user(); - [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); - [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); - - // Launch the published course as student2. - $student2mockcourselaunch = $this->get_mock_launch($publishedcourse, $studentusers[1], '23456'); - $launchservice->user_launches_tool($student2user, $student2mockcourselaunch); - - // Grade student1 in the assign resource. - $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); - - // And student2 in the course resource. - $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 55.5, $publishedcourse); - - // Sync and verify that no grades are sent because resource and published course are both not yet complete. - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". - "the course '$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 0 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Complete the resource for student1. - $this->override_resource_completion_status_for_user($resource, $student1id, true); - - // Run the sync again, this time confirming the grade for student1 is sent. - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.", - "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 1 grades.", - "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", - "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". - "the course '$course->id'.", - "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". - "Processed 1 users; sent 0 grades.", - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Fail completion for student1 and confirm no grade is sent, even despite it being changed. - $this->set_user_grade_for_resource($student1id, 33.3, $resource); - $this->override_resource_completion_status_for_user($resource, $student1id, false); - - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 0 grades.", - "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", - "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". - "the course '$course->id'.", - "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". - "Processed 1 users; sent 0 grades.", - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Complete the course for student2 and verify the grade is now sent. - $this->override_resource_completion_status_for_user($publishedcourse, $student2id, true); - - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 0 grades.", - "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", - "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ". - "'$publishedcourse->id' and the course '$course->id' was sent.", - "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". - "Processed 1 users; sent 1 grades.", - - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Mark the course as in progress again for student2 and verify any new grade changes are not sent. - $this->set_user_grade_for_resource($student2id, 78.8, $publishedcourse); - $this->override_resource_completion_status_for_user($publishedcourse, $student2id, false); - - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". - "the course '$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 0 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - } - - /** - * Test grade sync when the attempt to call the service returns an exception or a bad HTTP response code. - * - * @covers ::execute - */ - public function test_sync_grades_failed_service_call() { - $this->resetAfterTest(); - [$course, $resource] = $this->create_test_environment(); - $launchservice = $this->get_tool_launch_service(); - $task = $this->get_task_with_mocked_grade_service('200', true); - - // Launch the resource for an instructor which will create the domain objects needed for service calls. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); - $instructoruser = $this->getDataGenerator()->create_user(); - [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - // Launch the resource for a student, creating the enrolment and allowing grading to take place. - $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, - 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); - $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); - $student1user = $this->getDataGenerator()->create_user(); - [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); - - // Grade student1 in the assign resource. - $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); - - // Run the sync, verifying that the response error causes a 'Failed' trace but that the task completes. - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' failed to send.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 2 users; sent 0 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - - // Now run the sync again, this time with a bad http response code. - $task = $this->get_task_with_mocked_grade_service('400'); - ob_start(); - $task->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' failed to send.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 2 users; sent 0 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - } - - /** - * Test the sync when only the lineitem URL is provided and when lineitem creation/query isn't expected. - * - * @covers ::execute - */ - public function test_sync_grades_coupled_lineitem() { - $this->resetAfterTest(); - - [$course, $resource] = $this->create_test_environment(); - $launchservice = $this->get_tool_launch_service(); - - // The launches use a coupled line item. Only scores can be posted. Line items and results cannot be created or queried. - $agsclaim = [ - "scope" => ["https://purl.imsglobal.org/spec/lti-ags/scope/score"], - "lineitem" => "https://platform.example.com/10/lineitems/45/lineitem" - ]; - - // Launch the resource for an instructor which will create the domain objects needed for service calls. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], null, - $agsclaim); - $instructoruser = $this->getDataGenerator()->create_user(); - [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. - $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, - 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); - - $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0], null, $agsclaim); - $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1], null, $agsclaim); - $student1user = $this->getDataGenerator()->create_user(); - $student2user = $this->getDataGenerator()->create_user(); - [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); - [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); - - // Grade student1 only. - $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); - - // Mock task, asserting that score posting to an existing line item takes place, via a mock grade service object. - $mockgradeservice = $this->createMock(LtiAssignmentsGradesService::class); - $mockgradeservice->method('putGrade')->willReturnCallback(function() { - return ['headers' => ['httpstatus' => "HTTP/2 200 OK"], 'body' => '', 'status' => 200]; - }); - $mockgradeservice->expects($this->never()) - ->method('findOrCreateLineitem'); - $mockgradeservice->expects($this->once()) - ->method('putGrade') - ->with($this->isInstanceOf(LtiGrade::class)); - $mocktask = $this->getMockBuilder(sync_grades::class) - ->onlyMethods(['get_ags']) - ->getMock(); - $mocktask->method('get_ags')->willReturn($mockgradeservice); - - // Sync and verify that only student1's grade is sent. - ob_start(); - $mocktask->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.", - "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 1 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - } - - /** - * Test the sync when only the lineitems URL is provided and when line item creation/query is expected. - * - * @covers ::execute - */ - public function test_sync_grades_none_or_many_lineitems() { - $this->resetAfterTest(); - - [$course, $resource] = $this->create_test_environment(); - $launchservice = $this->get_tool_launch_service(); - - // The launches omit the 'lineitem' claim, meaning the item may have none (or many) line items. - $agsclaim = [ - "scope" => [ - "https://purl.imsglobal.org/spec/lti-ags/scope/score", - "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", - ], - "lineitems" => "https://platform.example.com/10/lineitems" - ]; - - // Launch the resource for an instructor which will create the domain objects needed for service calls. - $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], null, - $agsclaim); - $instructoruser = $this->getDataGenerator()->create_user(); - [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); - - // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. - $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, - 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); - - $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0], null, $agsclaim); - $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1], null, $agsclaim); - $student1user = $this->getDataGenerator()->create_user(); - $student2user = $this->getDataGenerator()->create_user(); - [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); - [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); - - // Grade student1 only. - $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); - - // Mock task, asserting that line item creation takes place via a mock grade service object. - $mockgradeservice = $this->createMock(LtiAssignmentsGradesService::class); - $mockgradeservice->method('putGrade')->willReturnCallback(function() { - return ['headers' => ['httpstatus' => "HTTP/2 200 OK"], 'body' => '', 'status' => 200]; - }); - $mockgradeservice->expects($this->once()) - ->method('findOrCreateLineitem'); - $mockgradeservice->expects($this->once()) - ->method('putGrade') - ->with($this->isInstanceOf(LtiGrade::class), $this->isInstanceOf(LtiLineitem::class)); - $mocktask = $this->getMockBuilder(sync_grades::class) - ->onlyMethods(['get_ags']) - ->getMock(); - $mocktask->method('get_ags')->willReturn($mockgradeservice); - - // Sync and verify that only student1's grade is sent. - ob_start(); - $mocktask->execute(); - $ob = ob_get_contents(); - ob_end_clean(); - $expectedtraces = [ - "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", - "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". - "'$resource->id' and the course '$course->id' was sent.", - "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". - "'$course->id'.", - "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". - "Processed 3 users; sent 1 grades." - ]; - foreach ($expectedtraces as $expectedtrace) { - $this->assertStringContainsString($expectedtrace, $ob); - } - } } diff --git a/enrol/lti/tests/local/ltiadvantage/task/sync_tool_grades_test.php b/enrol/lti/tests/local/ltiadvantage/task/sync_tool_grades_test.php new file mode 100644 index 00000000000..b1fab113e00 --- /dev/null +++ b/enrol/lti/tests/local/ltiadvantage/task/sync_tool_grades_test.php @@ -0,0 +1,827 @@ +. + +namespace enrol_lti\local\ltiadvantage\task; + +use enrol_lti\helper; +use Packback\Lti1p3\LtiAssignmentsGradesService; +use Packback\Lti1p3\LtiGrade; +use Packback\Lti1p3\LtiLineitem; +use core\task\manager; +use phpunit_util; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +require_once(__DIR__ . '/../lti_advantage_testcase.php'); + +/** + * Tests for the enrol_lti\local\ltiadvantage\task\sync_tool_grades adhoc task. + * + * @package enrol_lti + * @copyright 2023 David Pesce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \enrol_lti\local\ltiadvantage\task\sync_tool_grades + */ +class sync_tool_grades_test extends \lti_advantage_testcase { + /** + * Get a task which has a mocked ags instance injected, meaning no real calls will be made to the platform. + * + * This allows us to test the behaviour of the task (in terms of which users are in scope and which grades are sent) + * without needing to deal with any auth. + * + * @param string $statuscode the HTTP status code to simulate. + * @param bool $mockexception whether to simulate an exception during the service call or not. + * @return sync_grades instance of the task with a mocked ags instance inside. + */ + protected function get_task_with_mocked_grade_service($statuscode = '200', $mockexception = false): sync_tool_grades { + $mockgradeservice = $this->getMockBuilder(LtiAssignmentsGradesService::class) + ->disableOriginalConstructor() + ->onlyMethods(['putGrade']) + ->getMock(); + $mockgradeservice->method('putGrade')->willReturnCallback(function() use ($statuscode, $mockexception) { + if ($mockexception) { + throw new \Exception(); + } + return ['headers' => ['httpstatus' => "HTTP/2 $statuscode OK"], 'body' => '', 'status' => $statuscode]; + }); + // Get a mock task, with the method 'get_ags()' mocked to return the mocked AGS instance. + $mocktask = $this->getMockBuilder(sync_tool_grades::class) + ->onlyMethods(['get_ags']) + ->getMock(); + $mocktask->method('get_ags')->willReturn($mockgradeservice); + return $mocktask; + } + + /** + * Helper function to set a grade for a user. + * + * @param int $userid the id of the user being graded. + * @param float $grade the grade value, out of 100, to set for the user. + * @param \stdClass $resource the published resource object. + * @return float the fractional grade value expected to be used during sync. + */ + protected function set_user_grade_for_resource(int $userid, float $grade, \stdClass $resource): float { + + global $CFG; + require_once($CFG->libdir . '/accesslib.php'); + require_once($CFG->libdir . '/gradelib.php'); + $context = \context::instance_by_id($resource->contextid); + + if ($context->contextlevel == CONTEXT_COURSE) { + $gi = \grade_item::fetch_course_item($resource->courseid); + } else if ($context->contextlevel == CONTEXT_MODULE) { + $cm = get_coursemodule_from_id('assign', $context->instanceid); + + $gi = \grade_item::fetch([ + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $cm->instance, + 'courseid' => $resource->courseid + ]); + } + + if ($ggrade = \grade_grade::fetch(['itemid' => $gi->id, 'userid' => $userid])) { + $ggrade->finalgrade = $grade; + $ggrade->rawgrade = $grade; + $ggrade->update(); + } else { + $ggrade = new \grade_grade(); + $ggrade->itemid = $gi->id; + $ggrade->userid = $userid; + $ggrade->rawgrade = $grade; + $ggrade->finalgrade = $grade; + $ggrade->rawgrademax = 100; + $ggrade->rawgrademin = 0; + $ggrade->timecreated = time(); + $ggrade->timemodified = time(); + $ggrade->insert(); + } + return floatval($ggrade->finalgrade / $gi->grademax); + } + + /** + * Helper to set the completion status for published course or module. + * + * @param \stdClass $resource the resource - either a course or module. + * @param int $userid the id of the user to override the completion status for. + * @param bool $complete whether the resource is deemed complete or not. + */ + protected function override_resource_completion_status_for_user(\stdClass $resource, int $userid, + bool $complete): void { + + global $CFG; + require_once($CFG->libdir . '/accesslib.php'); + require_once($CFG->libdir . '/completionlib.php'); + require_once($CFG->libdir . '/datalib.php'); + $this->setAdminUser(); + $context = \context::instance_by_id($resource->contextid); + $completion = new \completion_info(get_course($resource->courseid)); + if ($context->contextlevel == CONTEXT_COURSE) { + $ccompletion = new \completion_completion(['userid' => $userid, 'course' => $resource->courseid]); + if ($complete) { + $ccompletion->mark_complete(); + } else { + $completion->clear_criteria(); + } + } else if ($context->contextlevel == CONTEXT_MODULE) { + $completion->update_state( + get_coursemodule_from_id('assign', $context->instanceid), + $complete ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE, + $userid, + true + ); + } + } + + /** + * Test the sync grades task during several runs and for a series of grade changes. + * + * @covers ::execute + */ + public function test_grade_sync_chronological_syncs() { + $this->resetAfterTest(); + + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); + $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); + + // Grade student1 only. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Sync and verify that only student1's grade is sent. + ob_start(); + $task->set_custom_data($resource); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Sync again, verifying no grades are sent because nothing has changed. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Not sent - The grade for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id' was not sent as the grades are the same.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Change student1's grade and add a grade for student2. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 68.5, $resource); + $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 44.5, $resource); + + // Sync again, verifying both grade changes are sent. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 2 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } + + /** + * Test a grade sync when there are more than one resource link for the resource. + * + * @covers ::execute + */ + public function test_grade_sync_multiple_resource_links() { + $this->resetAfterTest(); + + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service(); + + // Launch the resource first for an instructor using the default resource link in the platform. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch again as the instructor, this time from a different resource link in the platform. + $teachermocklaunch2 = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], 'RLID-2'); + $launchservice->user_launches_tool($instructoruser, $teachermocklaunch2); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + + $student1reslink1launch = $this->get_mock_launch($resource, $studentusers[0]); + $student2reslink1launch = $this->get_mock_launch($resource, $studentusers[1]); + $student1reslink2launch = $this->get_mock_launch($resource, $studentusers[1], 'RLID-2'); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1reslink1launch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2reslink1launch); + $launchservice->user_launches_tool($student1user, $student1reslink2launch); + + // Grade student1 only. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Sync and verify that only student1's grade is sent but that it's sent for BOTH resource links. + ob_start(); + $task->set_custom_data($resource); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Found 2 resource link(s) for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Verify that the grade was reported as being synced twice - once for each resource link. + $expected = "/Found 2 resource link\(s\) for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.\n". + "Processing resource link '.*'.\n". + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.\n". + "Processing resource link '.*'.\n". + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent./"; + $this->assertMatchesRegularExpression($expected, $ob); + } + + /** + * Test the grade sync task when the launch data doesn't include the AGS support. + * + * @covers ::execute + */ + public function test_sync_grades_no_service_endpoint() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], + null, null); + $instructoruser = $this->getDataGenerator()->create_user(); + [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + $task = $this->get_task_with_mocked_grade_service(); + $task->set_custom_data($resource); + $this->expectOutputRegex( + "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n". + "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ". + "course '$course->id'. Attempting to sync grades for all.\n". + "Processing resource link '.*'.\n". + "Skipping - No grade service found for the user '$userid', for the resource '$resource->id' and the ". + "course '$course->id'.\n". + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ". + "sent 0 grades./" + ); + $task->execute(); + } + + /** + * Test syncing grades when the enrolment instance is disabled. + * + * @covers ::execute + */ + public function test_sync_grades_disabled_instance() { + $this->resetAfterTest(); + global $DB; + + [$course, $resource, $resource2, $resource3] = $this->create_test_environment(); + + // Disable resource 1. + $enrol = (object) ['id' => $resource->enrolid, 'status' => ENROL_INSTANCE_DISABLED]; + $DB->update_record('enrol', $enrol); + + // Delete the activity being shared by resource 2, leaving resource 2 disabled as a result. + $modcontext = \context::instance_by_id($resource2->contextid); + course_delete_module($modcontext->instanceid); + + // Only the enabled resource 3 should sync grades. + $task = $this->get_task_with_mocked_grade_service(); + $task->set_custom_data($resource3); + $this->expectOutputRegex( + "/^Starting - LTI Advantage grade sync for shared resource '$resource3->id' in course '$course->id'.\n". + "Completed - Synced grades for tool '$resource3->id' in the course '$course->id'. Processed 0 users; ". + "sent 0 grades.\n$/" + ); + $task->execute(); + } + + /** + * Test the grade sync when the context has been deleted in between launch and when the grade sync task is run. + * + * @covers ::execute + */ + public function test_sync_grades_deleted_context() { + $this->resetAfterTest(); + global $DB; + + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Delete the activity, then enable the enrolment method (it is disabled during activity deletion). + $modcontext = \context::instance_by_id($resource->contextid); + course_delete_module($modcontext->instanceid); + $enrol = (object) ['id' => $resource->enrolid, 'status' => ENROL_INSTANCE_ENABLED]; + $DB->update_record('enrol', $enrol); + + $task = $this->get_task_with_mocked_grade_service(); + $task->set_custom_data($resource); + $this->expectOutputRegex( + "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n". + "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ". + "course '$course->id'. Attempting to sync grades for all.\n". + "Processing resource link '.*'.\n". + "Failed - Invalid contextid '$resource->contextid' for the resource '$resource->id'.\n". + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ". + "sent 0 grades./" + ); + $task->execute(); + } + + /** + * Test grade sync when completion is required for the activity before sync takes place. + * + * @covers ::execute + */ + public function test_sync_grades_completion_required() { + $this->resetAfterTest(); + global $CFG; + require_once($CFG->libdir . '/completionlib.php'); + + [ + $course, + $resource, + $resource2, + $publishedcourse + ] = $this->create_test_environment(true, true, false, helper::MEMBER_SYNC_ENROL_AND_UNENROL, true, true); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); + $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); + + // Launch the published course as student2. + $student2mockcourselaunch = $this->get_mock_launch($publishedcourse, $studentusers[1], '23456'); + $launchservice->user_launches_tool($student2user, $student2mockcourselaunch); + + // Grade student1 in the assign resource. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // And student2 in the course resource. + $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 55.5, $publishedcourse); + + // Since adhoc tasks are queued via sync_grades scheduled task, we need to create a queue. + $allitems = array($resource, $resource2, $publishedcourse); + + // Sync and verify that no grades are sent because resource and published course are both not yet complete. + ob_start(); + foreach ($allitems as $item) { + $task->set_custom_data($item); + $task->execute(); + } + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Complete the resource for student1. + $this->override_resource_completion_status_for_user($resource, $student1id, true); + + // Run the sync again, this time confirming the grade for student1 is sent. + ob_start(); + foreach ($allitems as $item) { + $task->set_custom_data($item); + $task->execute(); + } + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades.", + "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". + "Processed 1 users; sent 0 grades.", + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Fail completion for student1 and confirm no grade is sent, even despite it being changed. + $this->set_user_grade_for_resource($student1id, 33.3, $resource); + $this->override_resource_completion_status_for_user($resource, $student1id, false); + + ob_start(); + foreach ($allitems as $item) { + $task->set_custom_data($item); + $task->execute(); + } + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades.", + "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". + "Processed 1 users; sent 0 grades.", + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Complete the course for student2 and verify the grade is now sent. + $this->override_resource_completion_status_for_user($publishedcourse, $student2id, true); + + ob_start(); + foreach ($allitems as $item) { + $task->set_custom_data($item); + $task->execute(); + } + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades.", + "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", + "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ". + "'$publishedcourse->id' and the course '$course->id' was sent.", + "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". + "Processed 1 users; sent 1 grades.", + + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Mark the course as in progress again for student2 and verify any new grade changes are not sent. + $this->set_user_grade_for_resource($student2id, 78.8, $publishedcourse); + $this->override_resource_completion_status_for_user($publishedcourse, $student2id, false); + + ob_start(); + foreach ($allitems as $item) { + $task->set_custom_data($item); + $task->execute(); + } + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } + + /** + * Test grade sync when the attempt to call the service returns an exception or a bad HTTP response code. + * + * @covers ::execute + */ + public function test_sync_grades_failed_service_call() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service('200', true); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a student, creating the enrolment and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); + $student1user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + + // Grade student1 in the assign resource. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Run the sync, verifying that the response error causes a 'Failed' trace but that the task completes. + ob_start(); + $task->set_custom_data($resource); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' failed to send.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 2 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Now run the sync again, this time with a bad http response code. + $task = $this->get_task_with_mocked_grade_service('400'); + ob_start(); + $task->set_custom_data($resource); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' failed to send.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 2 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } + + /** + * Test the sync when only the lineitem URL is provided and when lineitem creation/query isn't expected. + * + * @covers ::execute + */ + public function test_sync_grades_coupled_lineitem() { + $this->resetAfterTest(); + + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + + // The launches use a coupled line item. Only scores can be posted. Line items and results cannot be created or queried. + $agsclaim = [ + "scope" => ["https://purl.imsglobal.org/spec/lti-ags/scope/score"], + "lineitem" => "https://platform.example.com/10/lineitems/45/lineitem" + ]; + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], null, + $agsclaim); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0], null, $agsclaim); + $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1], null, $agsclaim); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); + + // Grade student1 only. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Mock task, asserting that score posting to an existing line item takes place, via a mock grade service object. + $mockgradeservice = $this->createMock(LtiAssignmentsGradesService::class); + $mockgradeservice->method('putGrade')->willReturnCallback(function() { + return ['headers' => ['httpstatus' => "HTTP/2 200 OK"], 'body' => '', 'status' => 200]; + }); + $mockgradeservice->expects($this->never()) + ->method('findOrCreateLineitem'); + $mockgradeservice->expects($this->once()) + ->method('putGrade') + ->with($this->isInstanceOf(LtiGrade::class)); + $mocktask = $this->getMockBuilder(sync_tool_grades::class) + ->onlyMethods(['get_ags']) + ->getMock(); + $mocktask->method('get_ags')->willReturn($mockgradeservice); + + // Sync and verify that only student1's grade is sent. + ob_start(); + $mocktask->set_custom_data($resource); + $mocktask->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } + + /** + * Test the sync when only the lineitems URL is provided and when line item creation/query is expected. + * + * @covers ::execute + */ + public function test_sync_grades_none_or_many_lineitems() { + $this->resetAfterTest(); + + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + + // The launches omit the 'lineitem' claim, meaning the item may have none (or many) line items. + $agsclaim = [ + "scope" => [ + "https://purl.imsglobal.org/spec/lti-ags/scope/score", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + ], + "lineitems" => "https://platform.example.com/10/lineitems" + ]; + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], null, + $agsclaim); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0], null, $agsclaim); + $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1], null, $agsclaim); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); + + // Grade student1 only. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Mock task, asserting that line item creation takes place via a mock grade service object. + $mockgradeservice = $this->createMock(LtiAssignmentsGradesService::class); + $mockgradeservice->method('putGrade')->willReturnCallback(function() { + return ['headers' => ['httpstatus' => "HTTP/2 200 OK"], 'body' => '', 'status' => 200]; + }); + $mockgradeservice->expects($this->once()) + ->method('findOrCreateLineitem'); + $mockgradeservice->expects($this->once()) + ->method('putGrade') + ->with($this->isInstanceOf(LtiGrade::class), $this->isInstanceOf(LtiLineitem::class)); + $mocktask = $this->getMockBuilder(sync_tool_grades::class) + ->onlyMethods(['get_ags']) + ->getMock(); + $mocktask->method('get_ags')->willReturn($mockgradeservice); + + // Sync and verify that only student1's grade is sent. + ob_start(); + $mocktask->set_custom_data($resource); + $mocktask->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } +}