From 9ce3ee00fb2cc0cc81f14d738855da7b156e406e Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 1 Oct 2024 10:55:53 -0700 Subject: [PATCH 01/20] corrects closing of the users loop. fixes https://github.com/ilios/moodle-enrol-ilios/issues/50 --- lib.php | 111 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/lib.php b/lib.php index 23f35c6..c3f038a 100644 --- a/lib.php +++ b/lib.php @@ -315,7 +315,7 @@ public function sync($trace, $courseid = null): int { if (!empty($user->campusId)) { $urec = $DB->get_record('user', ["idnumber" => $user->campusId]); if (!empty($urec)) { - $iliosusers[$user->id] = [ 'id' => $urec->id, 'syncfield' => $urec->idnumber ]; + $iliosusers[$user->id] = ['id' => $urec->id, 'syncfield' => $urec->idnumber]; } } } @@ -346,19 +346,19 @@ public function sync($trace, $courseid = null): int { } // Don't re-enroll suspended enrollments for disabled Ilios users. - if (!empty($ue) && ENROL_USER_SUSPENDED === (int) $ue->status && !$user->enabled) { + if (!empty($ue) && ENROL_USER_SUSPENDED === (int)$ue->status && !$user->enabled) { continue; } // Flag actively enrolled users that are disabled in Ilios // for enrollment suspension further downstream. - if (!empty($ue) && ENROL_USER_ACTIVE === (int) $ue->status && !$user->enabled) { + if (!empty($ue) && ENROL_USER_ACTIVE === (int)$ue->status && !$user->enabled) { $suspendenrolments[] = $ue; continue; } // Continue if already enrolled with active status. - if (!empty($ue) && ENROL_USER_ACTIVE === (int) $ue->status) { + if (!empty($ue) && ENROL_USER_ACTIVE === (int)$ue->status) { continue; } @@ -371,7 +371,7 @@ public function sync($trace, $courseid = null): int { 0, ENROL_USER_ACTIVE ); - if (!empty($ue) && ENROL_USER_ACTIVE !== (int) $ue->status) { + if (!empty($ue) && ENROL_USER_ACTIVE !== (int)$ue->status) { $trace->output( "changing enrollment status to '" . ENROL_USER_ACTIVE @@ -389,70 +389,71 @@ public function sync($trace, $courseid = null): int { ); } } + } - // Suspend active enrollments for users that are disabled in Ilios. - foreach ($suspendenrolments as $ue) { - $trace->output( - "Suspending enrollment for disabled Ilios user: userid " - . " {$ue->userid} ==> courseid {$instance->courseid}." - , 1 - ); - $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); - } - - // Unenrol as necessary. + // Suspend active enrollments for users that are disabled in Ilios. + foreach ($suspendenrolments as $ue) { $trace->output( - "Unenrolling users from Course ID " - . $instance->courseid." with Role ID " - . $instance->roleid - . " that no longer associate with Ilios Sync ID " - . $instance->id - . "." + "Suspending enrollment for disabled Ilios user: userid " + . " {$ue->userid} ==> courseid {$instance->courseid}." + , 1 ); + $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); + } - $sql = "SELECT ue.* - FROM {user_enrolments} ue - WHERE ue.enrolid = $instance->id"; + // Unenrol as necessary. + $trace->output( + "Unenrolling users from Course ID " + . $instance->courseid." with Role ID " + . $instance->roleid + . " that no longer associate with Ilios Sync ID " + . $instance->id + . "." + ); - if (!empty($enrolleduserids)) { - $sql .= " AND ue.userid NOT IN ( ".implode(",", $enrolleduserids)." )"; - } + $sql = "SELECT ue.* + FROM {user_enrolments} ue + WHERE ue.enrolid = $instance->id"; - $rs = $DB->get_recordset_sql($sql); - foreach ($rs as $ue) { - if ($unenrolaction == ENROL_EXT_REMOVED_UNENROL) { - // Remove enrolment together with group membership, grades, preferences, etc. - $this->unenrol_user($instance, $ue->userid); + if (!empty($enrolleduserids)) { + $sql .= " AND ue.userid NOT IN ( ".implode(",", $enrolleduserids)." )"; + } + + $rs = $DB->get_recordset_sql($sql); + foreach ($rs as $ue) { + if ($unenrolaction == ENROL_EXT_REMOVED_UNENROL) { + // Remove enrolment together with group membership, grades, preferences, etc. + $this->unenrol_user($instance, $ue->userid); + $trace->output( + "unenrolling: $ue->userid ==> " + . $instance->courseid + . " via Ilios $synctype $syncid" + , 1 + ); + } else { // Would be ENROL_EXT_REMOVED_SUSPENDNOROLES. + // Just disable and ignore any changes. + if ($ue->status != ENROL_USER_SUSPENDED) { + $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); + $context = context_course::instance($instance->courseid); + role_unassign_all([ + 'userid' => $ue->userid, + 'contextid' => $context->id, + 'component' => 'enrol_ilios', + 'itemid' => $instance->id, + ]); $trace->output( - "unenrolling: $ue->userid ==> " + "suspending and unsassigning all roles: userid " + . $ue->userid + . " ==> courseid " . $instance->courseid - . " via Ilios $synctype $syncid" , 1 ); - } else { // Would be ENROL_EXT_REMOVED_SUSPENDNOROLES. - // Just disable and ignore any changes. - if ($ue->status != ENROL_USER_SUSPENDED) { - $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); - $context = context_course::instance($instance->courseid); - role_unassign_all([ - 'userid' => $ue->userid, - 'contextid' => $context->id, - 'component' => 'enrol_ilios', - 'itemid' => $instance->id, - ]); - $trace->output( - "suspending and unsassigning all roles: userid " - . $ue->userid - . " ==> courseid " - . $instance->courseid - , 1 - ); - } } } - $rs->close(); } + $rs->close(); } + $instances->close(); unset($iliosusers); From dc2213757b7677836b6270db3cdb87cba3c01bb5 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 1 Oct 2024 10:59:29 -0700 Subject: [PATCH 02/20] prevents learner enrolment on instructor sync if no instructors could be found. fixes https://github.com/ilios/moodle-enrol-ilios/issues/51 --- lib.php | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/lib.php b/lib.php index 23f35c6..bcbffa8 100644 --- a/lib.php +++ b/lib.php @@ -283,17 +283,19 @@ public function sync($trace, $courseid = null): int { $users = []; - if (!empty($instance->customint2) && !empty($group->instructors)) { - $trace->output( - "Enrolling instructors to Course ID " - . $instance->courseid - . " with Role ID " - . $instance->roleid - . " through Ilios Sync ID " - . $instance->id - . "." - ); - $users = $apiclient->get_by_ids($accesstoken, 'users', $group->instructors); + if (!empty($instance->customint2)) { + if (!empty($group->instructors)) { + $trace->output( + "Enrolling instructors to Course ID " + . $instance->courseid + . " with Role ID " + . $instance->roleid + . " through Ilios Sync ID " + . $instance->id + . "." + ); + $users = $apiclient->get_by_ids($accesstoken, 'users', $group->instructors); + } } else if (!empty($group->users)) { $trace->output( "Enrolling students to Course ID " From b7523d07bc8b246908ff33597834460b699edc0f Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 1 Oct 2024 11:02:39 -0700 Subject: [PATCH 03/20] check offering instructor groups before falling back to default instructors during sync. fixes https://github.com/ilios/moodle-enrol-ilios/issues/52 --- lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib.php b/lib.php index 23f35c6..1cc0cef 100644 --- a/lib.php +++ b/lib.php @@ -763,7 +763,7 @@ private function get_instructor_ids_from_group($grouptype, $groupid): array { $offerings = $apiclient->get_by_ids($accesstoken, 'offerings', $group->offerings); foreach ($offerings as $offering) { - if (empty($offering->instructors)) { + if (empty($offering->instructors) && empty($offering->instructorGroups)) { // No instructor AND no instructor groups have been set for this offering. // Fall back to the default instructors/instructor-groups defined for the learner group. $instructorids = array_merge($instructorids, $group->instructors); From 0ecfc66037ada6d6c28f724485c1e2400750384c Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 1 Oct 2024 12:01:15 -0700 Subject: [PATCH 04/20] increment version to indicate a patch release. --- version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.php b/version.php index 422baa3..739ea90 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024061000; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2024061001; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2023100400; // Requires this Moodle version. $plugin->component = 'enrol_ilios'; // Full name of the plugin (used for diagnostics). $plugin->release = 'v4.3'; From 3bfda0c98a7d1e410f1342218c3ca0988a3de383 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 18 Sep 2024 09:30:27 -0700 Subject: [PATCH 05/20] minimal upgrade to Moodle 4.4. --- .github/workflows/ci.yml | 4 ++-- version.php | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb9bc43..c1a1aa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,8 @@ jobs: strategy: fail-fast: false matrix: - php: ["8.1", "8.2"] - moodle-branch: ["MOODLE_403_STABLE"] + php: ["8.2", "8.3"] + moodle-branch: ["MOODLE_404_STABLE"] database: [pgsql, mariadb] steps: diff --git a/version.php b/version.php index 739ea90..03c03a8 100644 --- a/version.php +++ b/version.php @@ -25,12 +25,12 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024061001; // The current plugin version (Date: YYYYMMDDXX). -$plugin->requires = 2023100400; // Requires this Moodle version. +$plugin->version = 2024091800; // The current plugin version (Date: YYYYMMDDXX). +$plugin->requires = 2024041600; // Requires this Moodle version. $plugin->component = 'enrol_ilios'; // Full name of the plugin (used for diagnostics). -$plugin->release = 'v4.3'; -$plugin->supported = [403, 403]; -$plugin->maturity = MATURITY_STABLE; +$plugin->release = 'v4.4=-c1'; +$plugin->supported = [404, 404]; +$plugin->maturity = MATURITY_RC; $plugin->dependencies = [ 'local_iliosapiclient' => 2024061000, ]; From 0c3d020de3e2fc07547eb45f9609943a13e04df8 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 18 Sep 2024 10:05:48 -0700 Subject: [PATCH 06/20] adds the new Ilios API client and supporting infrastructure. copy/pasted from tool_ilioscategoryassigment. --- classes/ilios.php | 188 ++++++++++++++++++++ classes/tests/helper.php | 64 +++++++ lang/en/enrol_ilios.php | 9 + lang/en_us/enrol_ilios.php | 9 + tests/helper_test.php | 64 +++++++ tests/ilios_test.php | 350 +++++++++++++++++++++++++++++++++++++ 6 files changed, 684 insertions(+) create mode 100644 classes/ilios.php create mode 100644 classes/tests/helper.php create mode 100644 tests/helper_test.php create mode 100644 tests/ilios_test.php diff --git a/classes/ilios.php b/classes/ilios.php new file mode 100644 index 0000000..bb4b8b7 --- /dev/null +++ b/classes/ilios.php @@ -0,0 +1,188 @@ +. + +/** + * The Ilios API client. + * + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace enrol_ilios; + +use core\http_client; +use dml_exception; +use Firebase\JWT\JWT; +use GuzzleHttp\Exception\GuzzleException; +use moodle_exception; + +/** + * The Ilios API client. + * + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class ilios { + + /** + * @var string The API base path. + */ + const API_BASE_PATH = '/api/v3/'; + + /** + * @var string The API access token. + */ + protected string $accesstoken; + + /** + * @var string The Ilios API base URL. + */ + protected string $apibaseurl; + + /** + * Class constructor. + * @param http_client $httpclient The HTTP client. + * @throws dml_exception + */ + public function __construct( + /** @var http_client $httpclient The HTTP client */ + protected readonly http_client $httpclient + ) { + $this->accesstoken = get_config('enrol_ilios', 'apikey') ?: ''; + $hosturl = get_config('enrol_ilios', 'host_url') ?: ''; + $this->apibaseurl = rtrim($hosturl, '/') . self::API_BASE_PATH; + } + + /** + * Retrieves all schools from Ilios. + * + * @return array A list of school objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_schools(): array { + $response = $this->get('schools'); + return $response->schools; + } + + /** + * Retrieves all enabled users with a given primary school affiliation. + * + * @param int $schoolid The school ID. + * @return array A list of user objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_enabled_users_in_school(int $schoolid): array { + $response = $this->get('users?filters[enabled]=true&filters[school]=' . $schoolid); + return $response->users; + } + + + /** + * Sends a GET request to a given API endpoint with given options. + * + * @param string $path The target path fragment of the API request URL. May include query parameters. + * @param array $options Additional options. + * @return object The decoded response body. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get(string $path, array $options = []): object { + $this->validate_access_token($this->accesstoken); + + if (!array_key_exists('headers', $options) || empty($options['headers'])) { + $options = array_merge($options, ['headers' => [ + 'X-JWT-Authorization' => 'Token ' . $this->accesstoken, + ]]); + } + $response = $this->httpclient->get($this->apibaseurl . $path, $options); + return $this->parse_result($response->getBody()); + } + + /** + * Decodes and returns the given JSON-encoded input. + * + * @param string $str A JSON-encoded string + * @return object The JSON-decoded object representation of the given input. + * @throws moodle_exception + */ + protected function parse_result(string $str): object { + if (empty($str)) { + throw new moodle_exception('erroremptyresponse', 'enrol_ilios'); + } + $result = json_decode($str); + + if (empty($result)) { + throw new moodle_exception('errordecodingresponse', 'enrol_ilios'); + } + + if (isset($result->errors)) { + throw new moodle_exception( + 'errorresponsewitherror', + 'enrol_ilios', + '', + (string) $result->errors[0], + ); + } + + return $result; + } + + /** + * Validates the given access token. + * Will throw an exception if the token is not valid - that happens if the token is not set, cannot be decoded, or is expired. + * + * @param string $accesstoken the Ilios API access token + * @return void + * @throws moodle_exception + */ + protected function validate_access_token(string $accesstoken): void { + // Check if token is blank. + if ('' === trim($accesstoken)) { + throw new moodle_exception('erroremptytoken', 'enrol_ilios'); + } + + // Decode token payload. will throw an exception if this fails. + $tokenpayload = self::get_access_token_payload($accesstoken); + + // Check if token is expired. + if ($tokenpayload['exp'] < time()) { + throw new moodle_exception('errortokenexpired', 'enrol_ilios'); + } + } + + /** + * Decodes and retrieves the payload of the given access token. + * + * @param string $accesstoken the Ilios API access token + * @return array the token payload as key/value pairs. + * @throws moodle_exception + */ + public static function get_access_token_payload(string $accesstoken): array { + $parts = explode('.', $accesstoken); + if (count($parts) !== 3) { + throw new moodle_exception('errorinvalidnumbertokensegments', 'enrol_ilios'); + } + $payload = json_decode(JWT::urlsafeB64Decode($parts[1]), true); + if (!$payload) { + throw new moodle_exception('errordecodingtoken', 'enrol_ilios'); + } + return $payload; + } +} diff --git a/classes/tests/helper.php b/classes/tests/helper.php new file mode 100644 index 0000000..061b718 --- /dev/null +++ b/classes/tests/helper.php @@ -0,0 +1,64 @@ +. + +/** + * Provides utility methods for testing. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace enrol_ilios\tests; + +use DateTime; +use Firebase\JWT\JWT; + +/** + * A class providing utility methods for testing. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * Generates an un-expired JWT, to be used as access token. + * This token will pass client-side token validation. + * + * @return string + */ + public static function create_valid_ilios_api_access_token(): string { + $key = 'doesnotmatterhere'; + $payload = ['exp' => (new DateTime('10 days'))->getTimestamp()]; + return JWT::encode($payload, $key, 'HS256'); + } + + /** + * Generates an expired - and therefore invalid - JWT, to be used as access token. + * This token will fail client-side token validation. + * + * @return string + */ + public static function create_invalid_ilios_api_access_token(): string { + $key = 'doesnotmatterhere'; + $payload = ['exp' => (new DateTime('-2 days'))->getTimestamp()]; + return JWT::encode($payload, $key, 'HS256'); + } +} diff --git a/lang/en/enrol_ilios.php b/lang/en/enrol_ilios.php index 99aa898..f741967 100644 --- a/lang/en/enrol_ilios.php +++ b/lang/en/enrol_ilios.php @@ -35,6 +35,15 @@ $string['defaultlearnerrole'] = 'Default learner role'; $string['enrol'] = 'Enrol Ilios users'; $string['enrolusers'] = 'Enrol users'; +$string['errordecodingresponse'] = 'Failed to decode response.'; +$string['errordecodingtoken'] = 'Failed to decode API token.'; +$string['erroremptyresponse'] = 'Empty response.'; +$string['erroremptytoken'] = 'API token is empty.'; +$string['errorinvalidnumbertokensegments'] = 'API token has an incorrect number of segments.'; +$string['errorresponseentitynotfound'] = 'Cannot find {$a} in response.'; +$string['errorresponsewithcodeandmessage'] = 'Request failed. The API responded with the code: {$a->code} and message: {$a->message}.'; +$string['errorresponsewitherror'] = 'Request failed. The API responded with the following error: {$a}.'; +$string['errortokenexpired'] = 'API token is expired.'; $string['host_url'] = 'Host URL'; $string['host_url_desc'] = 'Type Ilios server IP address or host URL.'; $string['ilios'] = 'Ilios'; diff --git a/lang/en_us/enrol_ilios.php b/lang/en_us/enrol_ilios.php index 4b478c8..a7e93ec 100644 --- a/lang/en_us/enrol_ilios.php +++ b/lang/en_us/enrol_ilios.php @@ -35,6 +35,15 @@ $string['defaultlearnerrole'] = 'Default learner role'; $string['enrol'] = 'Enroll Ilios users'; $string['enrolusers'] = 'Enroll users'; +$string['errordecodingresponse'] = 'Failed to decode response.'; +$string['errordecodingtoken'] = 'Failed to decode API token.'; +$string['erroremptyresponse'] = 'Empty response.'; +$string['erroremptytoken'] = 'API token is empty.'; +$string['errorinvalidnumbertokensegments'] = 'API token has an incorrect number of segments.'; +$string['errorresponseentitynotfound'] = 'Cannot find {$a} in response.'; +$string['errorresponsewithcodeandmessage'] = 'Request failed. The API responded with the code: {$a->code} and message: {$a->message}.'; +$string['errorresponsewitherror'] = 'Request failed. The API responded with the following error: {$a}.'; +$string['errortokenexpired'] = 'API token is expired.'; $string['host_url'] = 'Host URL'; $string['host_url_desc'] = 'Type Ilios server IP address or host URL.'; $string['ilios'] = 'Ilios'; diff --git a/tests/helper_test.php b/tests/helper_test.php new file mode 100644 index 0000000..1ccf429 --- /dev/null +++ b/tests/helper_test.php @@ -0,0 +1,64 @@ +. + +/** + * Test coverage for the test helpers. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace enrol_ilios; + +use basic_testcase; +use moodle_exception; +use enrol_ilios\tests\helper; + +/** + * Tests the test helper class. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \enrol_ilios\tests\helper + */ +final class helper_test extends basic_testcase { + + /** + * Checks that the generator function creates a valid access token. + * @return void + * @throws moodle_exception + */ + public function test_create_valid_ilios_api_access_token(): void { + $accesstoken = helper::create_valid_ilios_api_access_token(); + $tokenpayload = ilios::get_access_token_payload($accesstoken); + $this->assertLessThan($tokenpayload['exp'], time(), 'Token expiration date is in the future.'); + } + + /** + * Checks that the generator function creates an invalid access token. + * @return void + * @throws moodle_exception + */ + public function test_create_invalid_ilios_api_access_token(): void { + $accesstoken = helper::create_invalid_ilios_api_access_token(); + $tokenpayload = ilios::get_access_token_payload($accesstoken); + $this->assertLessThan(time(), $tokenpayload['exp'], 'Token expiration date is in the past.'); + } +} diff --git a/tests/ilios_test.php b/tests/ilios_test.php new file mode 100644 index 0000000..de509a4 --- /dev/null +++ b/tests/ilios_test.php @@ -0,0 +1,350 @@ +. + +/** + * Test coverage for the Ilios API client. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace enrol_ilios; + +use advanced_testcase; +use core\di; +use core\http_client; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use moodle_exception; +use enrol_ilios\tests\helper; + +/** + * Tests the Ilios API client. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \enrol_ilios\ilios + */ +final class ilios_test extends advanced_testcase { + + /** + * Tests the happy path on get_schools(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_schools(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine'], + ['id' => 2, 'title' => 'Pharmacy'], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $schools = $ilios->get_schools(); + $this->assertCount(2, $schools); + $this->assertEquals(1, $schools[0]->id); + $this->assertEquals('Medicine', $schools[0]->title); + $this->assertEquals(2, $schools[1]->id); + $this->assertEquals('Pharmacy', $schools[1]->title); + } + + /** + * Tests the happy path on get_enabled_users_in_school(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_enabled_users_in_school(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'users' => [ + ['id' => 1, 'campusId' => 'xx00001'], + ['id' => 2, 'campusId' => 'xx00002'], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $users = $ilios->get_enabled_users_in_school(123); + $this->assertCount(2, $users); + $this->assertEquals(1, $users[0]->id); + $this->assertEquals('xx00001', $users[0]->campusId); + $this->assertEquals(2, $users[1]->id); + $this->assertEquals('xx00002', $users[1]->campusId); + } + + /** + * Tests the happy path on get(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine'], + ['id' => 2, 'title' => 'Pharmacy'], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $data = $ilios->get('schools'); + $this->assertCount(2, $data->schools); + $this->assertEquals(1, $data->schools[0]->id); + $this->assertEquals('Medicine', $data->schools[0]->title); + $this->assertEquals(2, $data->schools[1]->id); + $this->assertEquals('Pharmacy', $data->schools[1]->title); + } + + /** + * Tests that get() fails if the response cannot be JSON-decoded. + * + * @return void + * @throws GuzzleException + */ + public function test_get_fails_on_garbled_response(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], 'g00bleG0bble'), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Failed to decode response.'); + $ilios->get('schools'); + } + + /** + * Tests that get() fails if the response is empty. + * + * @return void + * @throws GuzzleException + */ + public function test_get_fails_on_empty_response(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], ''), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Empty response.'); + $ilios->get('schools'); + } + + /** + * Tests that get() fails if the response contains errors. + * + * @return void + * @throws GuzzleException + */ + public function test_get_fails_on_error_response(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['errors' => ['something went wrong']])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('The API responded with the following error: something went wrong.'); + $ilios->get('schools'); + } + + /** + * Tests that get() fails if the server response with a non 200 response, for example a 500 error. + * + * @return void + * @throws moodle_exception + */ + public function test_get_fails_on_server_side_error(): void { + $this->resetAfterTest(); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(500), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $this->expectException(GuzzleException::class); + // phpcs:disable moodle.Strings.ForbiddenStrings.Found + $this->expectExceptionMessage( + 'Server error: `GET http://ilios.demo/api/v3/schools` resulted in a `500 Internal Server Error` response' + ); + // phpcs:enable + $ilios->get('schools'); + } + + /** + * Tests that get() fails if the given access token is expired. + * + * @return void + * @throws GuzzleException + */ + public function test_get_fails_with_expired_token(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_invalid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('API token is expired.'); + $ilios->get('schools'); + } + + /** + * Tests that get() fails if the given access token is empty. + * + * @dataProvider empty_token_provider + * @param string $accesstoken The API access token. + * @return void + * @throws GuzzleException + */ + public function test_get_fails_with_empty_token(string $accesstoken): void { + $this->resetAfterTest(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('API token is empty.'); + $ilios->get('schools'); + } + + /** + * Tests that get() fails if the given access token cannot be JSON-decoded. + * + * @dataProvider corrupted_token_provider + * @param string $accesstoken The API access token. + * @return void + * @throws GuzzleException + */ + public function test_get_fails_with_corrupted_token(string $accesstoken): void { + $this->resetAfterTest(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Failed to decode API token.'); + $ilios->get('schools'); + } + + /** + * Tests that get() fails if the given access token has the wrong number of segments. + * + * @dataProvider invalid_token_provider + * @param string $accesstoken The API access token. + * @return void + * @throws GuzzleException + */ + public function test_get_fails_with_invalid_token(string $accesstoken): void { + $this->resetAfterTest(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('API token has an incorrect number of segments.'); + $ilios->get('schools'); + } + + /** + * Tests that get_access_token_payload() fails if the given access token has the wrong number of segments. + * + * @dataProvider invalid_token_provider + * @param string $accesstoken The API access token. + * @return void + */ + public function test_get_access_token_payload_fails_with_invalid_token(string $accesstoken): void { + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('API token has an incorrect number of segments.'); + ilios::get_access_token_payload($accesstoken); + } + + /** + * Tests that get_access_token_payload() fails if the given access token cannot be JSON-decoded. + * + * @dataProvider corrupted_token_provider + * @param string $accesstoken The API access token. + * @return void + */ + public function test_get_access_token_payload_fails_with_corrupted_token(string $accesstoken): void { + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Failed to decode API token.'); + ilios::get_access_token_payload($accesstoken); + } + + /** + * Returns empty access tokens. + * + * @return array[] + */ + public static function empty_token_provider(): array { + return [ + [''], + [' '], + ]; + } + + /** + * Returns "corrupted" access tokens. + * + * @return array[] + */ + public static function corrupted_token_provider(): array { + return [ + ['AAAAA.BBBBB.CCCCCC'], // Has the right number of segments, but bunk payload. + ]; + } + + /** + * Returns access tokens with invalid numbers of segments. + * + * @return array[] + */ + public static function invalid_token_provider(): array { + return [ + ['AAAA'], // Not enough segments. + ['AAAA.BBBBB'], // Still not enough. + ['AAAA.BBBBB.CCCCC.DDDDD'], // Too many segments. + ]; + } +} + From 1b6697c967fc9daec934b250db103054cbca9003 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 18 Sep 2024 11:06:13 -0700 Subject: [PATCH 07/20] wip: replace ilios client in enrollment plugin --- ajax.php | 63 +-- classes/ilios.php | 364 ++++++++++++++++-- edit.php | 12 +- edit_form.php | 84 ++-- lib.php | 203 ++-------- tests/ilios_test.php | 894 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 1313 insertions(+), 307 deletions(-) diff --git a/ajax.php b/ajax.php index 8f874a5..6976244 100644 --- a/ajax.php +++ b/ajax.php @@ -26,6 +26,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\di; +use enrol_ilios\ilios; + define('AJAX_SCRIPT', true); require('../../config.php'); @@ -63,15 +66,17 @@ $outcome->response = new stdClass(); $outcome->error = ''; -/** @var enrol_ilios_plugin $enrol */ -$enrol = enrol_get_plugin('ilios'); -$apiclient = $enrol->get_api_client(); -$accesstoken = $enrol->get_api_access_token(); +try { + $ilios = di::get(ilios::class); +} catch (Exception $e) { + // Re-throw exception. + throw new Exception('ERROR: Failed to instantiate Ilios client.', $e); +} switch ($action) { case 'getselectschooloptions': require_capability('moodle/course:enrolconfig', $context); - $schools = $apiclient->get($accesstoken, 'schools', '', ['title' => "ASC"]); + $schools = $ilios->get_schools(['title' => "ASC"]); $schoolarray = []; foreach ($schools as $school) { $schoolarray["$school->id:$school->title"] = $school->title; @@ -83,7 +88,7 @@ require_capability('moodle/course:enrolconfig', $context); $sid = required_param('filterid', PARAM_INT); // School ID. $programs = []; - $programs = $apiclient->get($accesstoken, 'programs', ['school' => $sid], ['title' => "ASC"]); + $programs = $ilios->get_programs(['school' => $sid], ['title' => "ASC"]); $programarray = []; foreach ($programs as $program) { $key = $program->id; @@ -101,12 +106,7 @@ case 'getselectcohortoptions': require_capability('moodle/course:enrolconfig', $context); $pid = required_param('filterid', PARAM_INT); - $programyears = $apiclient->get( - $accesstoken, - 'programYears', - ["program" => $pid], - ["startYear" => "ASC"] - ); + $programyears = $ilios->get_program_years(["program" => $pid], ["startYear" => "ASC"]); $programyeararray = []; $cohortoptions = []; foreach ($programyears as $progyear) { @@ -114,12 +114,7 @@ } if (!empty($programyeararray)) { - $cohorts = $apiclient->get( - $accesstoken, - 'cohorts', - ["programYear" => $programyeararray], - ["title" => "ASC"] - ); + $cohorts = $ilios->get_cohorts(["programYear" => $programyeararray], ["title" => "ASC"]); foreach ($cohorts as $cohort) { $cohortoptions["$cohort->id:$cohort->title"] = $cohort->title .' ('.count($cohort->learnerGroups).')' @@ -133,12 +128,7 @@ require_capability('moodle/course:enrolconfig', $context); $cid = required_param('filterid', PARAM_INT); // Cohort ID. $usertype = optional_param('usertype', 0, PARAM_INT); // Learner or instructor. - $learnergroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ['cohort' => $cid, 'parent' => 'null'], - ['title' => "ASC"] - ); + $learnergroups = $ilios->get_learner_groups(['cohort' => $cid, 'parent' => 'null'], ['title' => "ASC"]); $grouparray = []; foreach ($learnergroups as $group) { $grouparray["$group->id:$group->title"] = $group->title. @@ -153,25 +143,15 @@ $gid = required_param('filterid', PARAM_INT); // Group ID. $usertype = optional_param('usertype', 0, PARAM_INT); // Learner or instructor. $subgroupoptions = []; - $subgroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ["parent" => $gid], - ["title" => "ASC"] - ); + $subgroups = $ilios->get_learner_groups(["parent" => $gid], ["title" => "ASC"]); foreach ($subgroups as $subgroup) { $subgroupoptions["$subgroup->id:$subgroup->title"] = $subgroup->title. ' ('. count($subgroup->children) .')'; $subgroupoptions["$subgroup->id:$subgroup->title"] .= ' ('. count($subgroup->users) .')'; if (!empty($subgroup->children)) { - $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions, $apiclient, $accesstoken) { - $subgrps = $apiclient->get( - $accesstoken, - 'learnerGroups', - [ 'parent' => $parent->id], - [ 'title' => "ASC"] - ); + $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions, $ilios) { + $subgrps = $ilios->get_learner_groups([ 'parent' => $parent->id], [ 'title' => "ASC"]); foreach ($subgrps as $subgrp) { $subgroupoptions["$subgrp->id:$parent->title / $subgrp->title"] = $parent->title.' / '.$subgrp->title. ' ('. count($subgrp->children) .')'; @@ -192,14 +172,9 @@ require_capability('moodle/course:enrolconfig', $context); $gid = required_param('filterid', PARAM_INT); // Group ID. $instructorgroupoptions = []; - $learnergroup = $apiclient->get_by_id($accesstoken, 'learnerGroups', $gid); + $learnergroup = $ilios->get_learner_group('learnerGroups', $gid); if (!empty($learnergroup->instructorGroups)) { - $instructorgroups = $apiclient->get( - $accesstoken, - 'instructorGroups', - '', - ["title" => "ASC"] - ); + $instructorgroups = $ilios->get_instructor_groups(sortby: ["title" => "ASC"]); foreach ($instructorgroups as $instructorgroup) { $instructorgroupoptions["$instructorgroup->id:$instructorgroup->title"] = $instructorgroup->title. ' ('. count($instructorgroup->users) .')'; diff --git a/classes/ilios.php b/classes/ilios.php index bb4b8b7..29aafc5 100644 --- a/classes/ilios.php +++ b/classes/ilios.php @@ -27,6 +27,7 @@ use core\http_client; use dml_exception; use Firebase\JWT\JWT; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use moodle_exception; @@ -69,41 +70,296 @@ public function __construct( } /** - * Retrieves all schools from Ilios. + * Retrieves a list of schools from Ilios. * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. * @return array A list of school objects. * @throws GuzzleException * @throws moodle_exception */ - public function get_schools(): array { - $response = $this->get('schools'); + public function get_schools(array $filterby = [], array $sortby = []): array { + $response = $this->get('schools', $filterby, $sortby); return $response->schools; } /** - * Retrieves all enabled users with a given primary school affiliation. + * Retrieves a list of cohorts from Ilios. * - * @param int $schoolid The school ID. + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of cohort objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_cohorts(array $filterby = [], array $sortby = []): array { + $response = $this->get('cohorts', $filterby, $sortby); + return $response->cohorts; + } + + /** + * Retrieves a list of programs from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of program objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_programs(array $filterby = [], array $sortby = []): array { + $response = $this->get('programs', $filterby, $sortby); + return $response->programs; + } + + /** + * Retrieves a list of program-years from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of program-year objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_program_years(array $filterby = [], array $sortby = []): array { + $response = $this->get('programyears', $filterby, $sortby); + return $response->programYears; + } + + /** + * Retrieves a list of learner-groups from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of learner-group objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_learner_groups(array $filterby = [], array $sortby = []): array { + $response = $this->get('learnergroups', $filterby, $sortby); + return $response->learnerGroups; + } + + /** + * Retrieves a list of instructor-groups from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of instructor-group objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_instructor_groups(array $filterby = [], array $sortby = []): array { + $response = $this->get('instructorgroups', $filterby, $sortby); + return $response->instructorGroups; + } + + /** + * Retrieves a list of offerings from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of offering objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_offerings(array $filterby = [], array $sortby = []): array { + $response = $this->get('offerings', $filterby, $sortby); + return $response->offerings; + } + + /** + * Retrieves a list of ILMs from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of ILM objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_ilms(array $filterby = [], array $sortby = []): array { + $response = $this->get('ilmsessions', $filterby, $sortby); + return $response->ilmSessions; + } + + /** + * Retrieves a list of users from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. * @return array A list of user objects. * @throws GuzzleException * @throws moodle_exception */ - public function get_enabled_users_in_school(int $schoolid): array { - $response = $this->get('users?filters[enabled]=true&filters[school]=' . $schoolid); + public function get_users(array $filterby = [], array $sortby = []): array { + $response = $this->get('users', $filterby, $sortby); return $response->users; } + /** + * Retrieves a school by its ID from Ilios. + * + * @param int $id + * @return object|null The school object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_school(int $id): ?object { + $response = $this->get_by_id('schools', $id); + if ($response) { + return $response->schools[0]; + } + return null; + } + + /** + * Retrieves a cohort by its ID from Ilios. + * + * @param int $id + * @return object|null The cohort object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_cohort(int $id): ?object { + $response = $this->get_by_id('cohorts', $id); + if ($response) { + return $response->cohorts[0]; + } + return null; + } + + /** + * Retrieves a program by its ID from Ilios. + * + * @param int $id + * @return object|null The program object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_program(int $id): ?object { + $response = $this->get_by_id('programs', $id); + if ($response) { + return $response->programs[0]; + } + return null; + } + + /** + * Retrieves a learner-group by its ID from Ilios. + * + * @param int $id + * @return object|null The learner-group object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_learner_group(int $id): ?object { + $response = $this->get_by_id('learnergroups', $id); + if ($response) { + return $response->learnerGroups[0]; + } + return null; + } + + /** + * Retrieves a list instructors for a given learner-group and its subgroups. + * + * @param int $groupid The group ID. + * @return array A list of user IDs. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_instructor_ids_from_learner_group(int $groupid): array { + $group = $this->get_learner_group($groupid); + // No group, no instructors. + if (empty($group)) { + return []; + } + + $instructorgroupids = []; + $instructorids = []; + + // Get instructors/instructor-groups from the offerings that this learner group is being taught in. + if (!empty($group->offerings)) { + $offerings = $this->get_offerings(['id' => $group->offerings]); + + foreach ($offerings as $offering) { + if (empty($offering->instructors) && empty($offering->instructorGroups)) { + // No instructor AND no instructor groups have been set for this offering. + // Fall back to the default instructors/instructor-groups defined for the learner group. + $instructorids = array_merge($instructorids, $group->instructors); + $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); + } else { + // If there are instructors and/or instructor-groups set on the offering, then use these. + $instructorids = array_merge($instructorids, $offering->instructors); + $instructorgroupids = array_merge($instructorgroupids, $offering->instructorGroups); + } + } + } + + // Get instructors/instructor-groups from the ilm sessions that this learner group is being taught in. + // This is a rinse/repeat from offerings-related code above. + if (!empty($group->ilmSessions)) { + $ilms = $this->get_ilms(['id' => $group->ilmSessions]); + + foreach ($ilms as $ilm) { + if (empty($ilm->instructors) && empty($ilm->instructorGroups)) { + // No instructor AND no instructor groups have been set for this offering. + // Fall back to the default instructors/instructor-groups defined for the learner group. + $instructorids = array_merge($instructorids, $group->instructors); + $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); + } else { + // If there are instructors and/or instructor-groups set on the offering, then use these. + $instructorids = array_merge($instructorids, $ilm->instructors); + $instructorgroupids = array_merge($instructorgroupids, $ilm->instructorGroups); + } + } + } + + // Get instructors from sub-learner-groups. + if (!empty($group->children)) { + foreach ($group->children as $subgroupid) { + $instructorids = array_merge( + $instructorids, + $this->get_instructor_ids_from_learner_group($subgroupid) + ); + // We don't care about instructor groups here, + // we will merge instructor groups into the $instructorIds array later. + } + } + + // Next, get the ids of all instructors from the instructor-groups that we determined as relevant earlier. + // But first let's de-dupe them. + $instructorgroupids = array_unique($instructorgroupids); + if (!empty($instructorgroupids)) { + $instructorgroups = $this->get_instructor_groups(['id' => $instructorgroupids]); + foreach ($instructorgroups as $instructorgroup) { + $instructorids = array_merge($instructorids, $instructorgroup->users); + } + } + + // Finally, we retrieve all the users that were identified as relevant instructors earlier. + $instructorids = array_unique($instructorids); + asort($instructorids); + return array_values($instructorids); + } + /** * Sends a GET request to a given API endpoint with given options. * * @param string $path The target path fragment of the API request URL. May include query parameters. + * @param array $filterby An associative array of filter options. + * @param array $sortby An associative array of sort options. * @param array $options Additional options. * @return object The decoded response body. * @throws GuzzleException * @throws moodle_exception */ - public function get(string $path, array $options = []): object { + public function get( + string $path, + array $filterby = [], + array $sortby = [], + array $options = [] + ): object { $this->validate_access_token($this->accesstoken); if (!array_key_exists('headers', $options) || empty($options['headers'])) { @@ -111,10 +367,81 @@ public function get(string $path, array $options = []): object { 'X-JWT-Authorization' => 'Token ' . $this->accesstoken, ]]); } - $response = $this->httpclient->get($this->apibaseurl . $path, $options); + + // Construct query params from given filters and sort orders. + // Unfortunately, http_build_query() doesn't cut it here, so we have to hand-roll this. + $queryparams = []; + if (!empty($filterby)) { + foreach ($filterby as $param => $value) { + if (is_array($value)) { + foreach ($value as $val) { + $queryparams[] = "filters[$param][]=$val"; + } + } else { + $queryparams[] = "filters[$param]=$value"; + } + } + } + + if (!empty($sortby)) { + foreach ($sortby as $param => $value) { + $queryparams[] = "order_by[$param]=$value"; + } + } + + $url = $this->apibaseurl . $path; + + if (!empty($queryparams)) { + $url .= '?' . implode('&', $queryparams); + } + + $response = $this->httpclient->get($url, $options); return $this->parse_result($response->getBody()); } + /** + * Decodes and retrieves the payload of the given access token. + * + * @param string $accesstoken the Ilios API access token + * @return array the token payload as key/value pairs. + * @throws moodle_exception + */ + public static function get_access_token_payload(string $accesstoken): array { + $parts = explode('.', $accesstoken); + if (count($parts) !== 3) { + throw new moodle_exception('errorinvalidnumbertokensegments', 'enrol_ilios'); + } + $payload = json_decode(JWT::urlsafeB64Decode($parts[1]), true); + if (!$payload) { + throw new moodle_exception('errordecodingtoken', 'enrol_ilios'); + } + return $payload; + } + + /** + * Retrieves a given resource from Ilios by its given ID. + * + * @param string $path The URL path fragment that names the resource. + * @param int $id The ID. + * @param bool $returnnullonnotfound If TRUE then NULL is returned if the resource cannot be found. + * On FALSE, an exception is raised on 404/Not-Found. + * Defaults to TRUE. + * @return object|null The resource object, or NULL. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_by_id(string $path, int $id, bool $returnnullonnotfound = true): ?object { + try { + return $this->get($path . '/' . $id); + } catch (ClientException $e) { + if ($returnnullonnotfound && (404 === $e->getResponse()->getStatusCode())) { + return null; + } + // Re-throw the exception otherwise. + throw $e; + } + } + /** * Decodes and returns the given JSON-encoded input. * @@ -166,23 +493,4 @@ protected function validate_access_token(string $accesstoken): void { throw new moodle_exception('errortokenexpired', 'enrol_ilios'); } } - - /** - * Decodes and retrieves the payload of the given access token. - * - * @param string $accesstoken the Ilios API access token - * @return array the token payload as key/value pairs. - * @throws moodle_exception - */ - public static function get_access_token_payload(string $accesstoken): array { - $parts = explode('.', $accesstoken); - if (count($parts) !== 3) { - throw new moodle_exception('errorinvalidnumbertokensegments', 'enrol_ilios'); - } - $payload = json_decode(JWT::urlsafeB64Decode($parts[1]), true); - if (!$payload) { - throw new moodle_exception('errordecodingtoken', 'enrol_ilios'); - } - return $payload; - } } diff --git a/edit.php b/edit.php index 25b262f..6a670fc 100644 --- a/edit.php +++ b/edit.php @@ -23,6 +23,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\di; +use enrol_ilios\ilios; + require('../../config.php'); require_once("$CFG->dirroot/enrol/ilios/edit_form.php"); require_once("$CFG->dirroot/group/lib.php"); @@ -45,6 +48,13 @@ redirect($returnurl); } +try { + $ilios = di::get(ilios::class); +} catch (Exception $e) { + // Re-throw exception. + throw new Exception('ERROR: Failed to instantiate Ilios client.', $e); +} + /** @var enrol_ilios_plugin $enrol */ $enrol = enrol_get_plugin('ilios'); @@ -74,7 +84,7 @@ $courseadmin->get('users')->get('manageinstances')->make_active(); } -$mform = new enrol_ilios_edit_form(null, [$instance, $enrol, $course]); +$mform = new enrol_ilios_edit_form(null, [$instance, $enrol, $course, $ilios]); if ($mform->is_cancelled()) { redirect($returnurl); diff --git a/edit_form.php b/edit_form.php index 7aede27..72b853f 100644 --- a/edit_form.php +++ b/edit_form.php @@ -23,6 +23,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\di; +use enrol_ilios\ilios; + defined('MOODLE_INTERNAL') || die(); require_once("$CFG->libdir/formslib.php"); @@ -37,6 +40,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class enrol_ilios_edit_form extends moodleform { + /** * Form definition. * @@ -46,11 +50,11 @@ class enrol_ilios_edit_form extends moodleform { * @throws moodle_exception */ protected function definition(): void { - global $CFG, $DB, $PAGE; + global $DB, $PAGE; $mform = $this->_form; /* @var enrol_ilios_plugin $plugin This enrolment plugin. */ - list($instance, $plugin, $course) = $this->_customdata; + list($instance, $plugin, $course, $ilios) = $this->_customdata; $coursecontext = context_course::instance($course->id); $PAGE->requires->yui_module( @@ -65,8 +69,6 @@ protected function definition(): void { ); $enrol = $plugin; - $apiclient = $plugin->get_api_client(); - $accesstoken = $plugin->get_api_access_token(); $mform->addElement('header', 'general', get_string('pluginname', 'enrol_ilios')); @@ -96,12 +98,12 @@ protected function definition(): void { $syncinfo = json_decode($instance->customtext1); $instance->schoolid = $syncinfo->school->id; - $school = $apiclient->get_by_id($accesstoken, 'schools', $instance->schoolid); + $school = $ilios->get_school($instance->schoolid); $instance->selectschoolindex = "$instance->schoolid:$school->title"; $schooloptions = [ $instance->selectschoolindex => $school->title ]; $instance->programid = $syncinfo->program->id; - $program = $apiclient->get_by_id($accesstoken, 'programs', $instance->programid); + $program = $ilios->get_program($instance->programid); $instance->selectprogramindex = $instance->programid; foreach (['shortTitle', 'title'] as $attr) { @@ -113,7 +115,7 @@ protected function definition(): void { $programoptions = [ $instance->selectprogramindex => $program->title ]; $instance->cohortid = $syncinfo->cohort->id; - $cohort = $apiclient->get_by_id($accesstoken, 'cohorts', $instance->cohortid); + $cohort = $ilios->get_cohort($instance->cohortid); $instance->selectcohortindex = "$instance->cohortid:$cohort->title"; $cohortoptions = [ $instance->selectcohortindex => $cohort->title @@ -127,11 +129,7 @@ protected function definition(): void { if ($synctype == 'learnerGroup') { $instance->learnergroupid = $syncid; - if (!empty($instance->customint2)) { - $group = $enrol->get_group_data('learnerGroup', $instance->learnergroupid); - } else { - $group = $apiclient->get_by_id($accesstoken, 'learnerGroups', $instance->learnergroupid); - } + $group = $ilios->get_learner_group($instance->learnergroupid); if ($group) { $instance->selectlearnergroupindex = "$instance->learnergroupid:$group->title"; @@ -141,14 +139,13 @@ protected function definition(): void { $learnergroupoptions = [$instance->selectlearnergroupindex => $grouptitle]; if (!empty($group->parent)) { - $processparents = function ($child) use (&$processparents, + $processparents = function ($child) use ( + &$processparents, &$learnergroupoptions, &$grouptitle, - &$instance, - $apiclient, - $accesstoken + &$instance ) { - $parentgroup = $apiclient->get_by_id($accesstoken, 'learnerGroups', $child->parent); + $parentgroup = $ilios->get_learner_group($child->parent); $instance->learnergroupid = $parentgroup->id; $instance->selectlearnergroupindex = "$instance->learnergroupid:$parentgroup->title"; $learnergroupoptions = [ @@ -306,7 +303,6 @@ protected function definition(): void { * @throws moodle_exception */ public function definition_after_data(): void { - global $DB; $mform = $this->_form; $progel = $mform->getElement('selectschool'); @@ -314,10 +310,7 @@ public function definition_after_data(): void { return; } - /* @var enrol_ilios_plugin $enrol This enrolment plugin. */ - $enrol = enrol_get_plugin('ilios'); - $apiclient = $enrol->get_api_client(); - $accesstoken = $enrol->get_api_access_token(); + list($instance, $enrol, $course, $ilios) = $this->_customdata; $selectvalues = $mform->getElementValue('selectschool'); if (is_array($selectvalues)) { @@ -367,7 +360,7 @@ public function definition_after_data(): void { $learnergrouptitle = ''; } - $schools = $apiclient->get($accesstoken, 'schools', '', ['title' => "ASC"]); + $schools = $ilios->get_schools(sortby: ['title' => 'ASC']); $progel =& $mform->getElement('selectschool'); if ($schools === null) { // No connection to the server. @@ -384,13 +377,7 @@ public function definition_after_data(): void { $sid = $schoolid; $progel =& $mform->getElement('selectprogram'); $programoptions = []; - $programs = []; - $programs = $apiclient->get( - $accesstoken, - 'programs', - ['school' => $sid], - ['title' => "ASC"] - ); + $programs = $ilios->get_programs(['school' => $sid], ['title' => "ASC"]); if (!empty($programs)) { foreach ($programs as $program) { @@ -412,24 +399,14 @@ public function definition_after_data(): void { $progel =& $mform->getElement('selectcohort'); $cohortoptions = []; - $programyears = $apiclient->get( - $accesstoken, - 'programYears', - ["program" => $pid], - ["startYear" => "ASC"] - ); + $programyears = $ilios->get_program_years(["program" => $pid], ["startYear" => "ASC"]); $programyeararray = []; foreach ($programyears as $progyear) { $programyeararray[] = $progyear->id; } if (!empty($programyeararray)) { - $cohorts = $apiclient->get( - $accesstoken, - 'cohorts', - ["programYear" => $programyeararray], - ["title" => "ASC"] - ); + $cohorts = $ilios->get_cohorts(["programYear" => $programyeararray], ["title" => "ASC"]); foreach ($cohorts as $cohort) { $cohortoptions["$cohort->id:$cohort->title"] = $cohort->title @@ -445,12 +422,7 @@ public function definition_after_data(): void { $progel =& $mform->getElement('selectlearnergroup'); $learnergroupoptions = []; - $learnergroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ['cohort' => $cid, 'parent' => 'null'], - ['title' => "ASC"] - ); + $learnergroups = $ilios->get_learner_groups(['cohort' => $cid, 'parent' => 'null'], ['title' => "ASC"]); if (!empty($learnergroups)) { foreach ($learnergroups as $group) { $learnergroupoptions["$group->id:$group->title"] = $group->title. @@ -466,24 +438,14 @@ public function definition_after_data(): void { $progel =& $mform->getElement('selectsubgroup'); $subgroupoptions = []; - $subgroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ["parent" => $gid], - ["title" => "ASC"] - ); + $subgroups = $ilios->get_learner_groups(["parent" => $gid], ["title" => "ASC"]); foreach ($subgroups as $subgroup) { $subgroupoptions["$subgroup->id:$subgroup->title"] = $subgroup->title. ' ('. count($subgroup->children) .')'. ' ('. count($subgroup->users) .')'; if (!empty($subgroup->children)) { - $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions, $apiclient, $accesstoken) { - $subgrps = $apiclient->get( - $accesstoken, - 'learnerGroups', - [ 'parent' => $parent->id], - [ 'title' => "ASC"] - ); + $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions) { + $subgrps = $ilios->get_learner_groups([ 'parent' => $parent->id], [ 'title' => "ASC"]); foreach ($subgrps as $subgrp) { $subgroupoptions["$subgrp->id:$parent->title / $subgrp->title"] = $parent->title.' / '.$subgrp->title. ' ('. count($subgrp->children) .')'. diff --git a/lib.php b/lib.php index dd7a9c8..820a823 100644 --- a/lib.php +++ b/lib.php @@ -23,7 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use local_iliosapiclient\ilios_client; +use core\di; +use enrol_ilios\ilios; defined('MOODLE_INTERNAL') || die(); @@ -36,20 +37,20 @@ */ class enrol_ilios_plugin extends enrol_plugin { /** - * @var ilios_client The Ilios API client. + * @var ilios The Ilios API client. */ - protected ilios_client $apiclient; - - /** - * @var string the plugin settings key for the API access token. - */ - public const SETTINGS_API_ACCESS_TOKEN = 'apikey'; + protected ilios $ilios; /** * Constructor. */ public function __construct() { - $this->apiclient = new ilios_client($this->get_config('host_url', ''), new curl()); + try { + $this->ilios = di::get(ilios::class); + } catch (Exception $e) { + // Re-throw exception. + throw new Exception('ERROR: Failed to instantiate Ilios client.', $e); + } } /** @@ -76,24 +77,6 @@ public function can_hide_show_instance($instance): bool { return has_capability('enrol/ilios:config', $context); } - /** - * Returns the Ilios Client for API access. - * - * @return ilios_client The Ilios API client. - */ - public function get_api_client(): ilios_client { - return $this->apiclient; - } - - /** - * Retrieves the Ilios API access token from the plugin configuration. - * - * @return string The API access token. - */ - public function get_api_access_token(): string { - return $this->get_config(self::SETTINGS_API_ACCESS_TOKEN, ''); - } - /** * Returns localised name of enrol instance. * @@ -228,9 +211,6 @@ public function sync($trace, $courseid = null): int { global $CFG, $DB; require_once($CFG->dirroot . '/group/lib.php'); - $apiclient = $this->get_api_client(); - $accesstoken = $this->get_api_access_token(); - if (!enrol_is_enabled('ilios')) { // Purge all roles if ilios sync disabled, those can be recreated later here by cron or CLI. $trace->output('Ilios enrolment sync plugin is disabled, unassigning all plugin roles and stopping.'); @@ -264,27 +244,28 @@ public function sync($trace, $courseid = null): int { $synctype = $instance->customchar1; $syncid = $instance->customint1; - if (!empty($instance->customint2)) { - // Need to get instructor ids. This function takes longer to run. - $group = $this->get_group_data($synctype, $syncid); + if ('learnerGroup' === $synctype) { + $entity = $this->ilios->get_learner_group($syncid); } else { - // No need to get instructor ids. - $group = $apiclient->get_by_id($accesstoken, $synctype.'s', $syncid); + $entity = $this->ilios->get_cohort($syncid); } - if (empty($group)) { + if (empty($entity)) { $trace->output("skipping: Unable to fetch data for Ilios $synctype ID $syncid.", 1); continue; } $enrolleduserids = []; // Keep a list of enrolled user's Moodle userid (both learners and instructors). - $users = []; // Ilios users in that group. $suspendenrolments = []; // List of user enrollments to suspend. - $users = []; + $users = []; // Ilios users in that group. if (!empty($instance->customint2)) { - if (!empty($group->instructors)) { + $instructors = []; + if ('learnerGroup' === $synctype && !empty($instance->customint2)) { + $instructors = $this->ilios->get_instructor_ids_from_learner_group($entity->id); + } + if (!empty($instructors)) { $trace->output( "Enrolling instructors to Course ID " . $instance->courseid @@ -294,9 +275,9 @@ public function sync($trace, $courseid = null): int { . $instance->id . "." ); - $users = $apiclient->get_by_ids($accesstoken, 'users', $group->instructors); + $users = $this->ilios->get_users(['id' => $instructors]); } - } else if (!empty($group->users)) { + } else if (!empty($entity->users)) { $trace->output( "Enrolling students to Course ID " . $instance->courseid @@ -306,7 +287,7 @@ public function sync($trace, $courseid = null): int { $instance->id . "." ); - $users = $apiclient->get_by_ids($accesstoken, 'users', $group->users); + $users = $this->ilios->get_users(['id' => $entity->users]); } $trace->output(count($users) . " Ilios users found."); @@ -432,17 +413,22 @@ public function sync($trace, $courseid = null): int { . " via Ilios $synctype $syncid" , 1 ); - } else { // Would be ENROL_EXT_REMOVED_SUSPENDNOROLES. - // Just disable and ignore any changes. - if ($ue->status != ENROL_USER_SUSPENDED) { - $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); - $context = context_course::instance($instance->courseid); - role_unassign_all([ - 'userid' => $ue->userid, - 'contextid' => $context->id, - 'component' => 'enrol_ilios', - 'itemid' => $instance->id, - ]); + $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); + } + + $sql = "SELECT ue.* + FROM {user_enrolments} ue + WHERE ue.enrolid = $instance->id"; + + if (!empty($enrolleduserids)) { + $sql .= " AND ue.userid NOT IN ( ".implode(",", $enrolleduserids)." )"; + } + + $rs = $DB->get_recordset_sql($sql); + foreach ($rs as $ue) { + if ($unenrolaction == ENROL_EXT_REMOVED_UNENROL) { + // Remove enrolment together with group membership, grades, preferences, etc. + $this->unenrol_user($instance, $ue->userid); $trace->output( "suspending and unsassigning all roles: userid " . $ue->userid @@ -714,117 +700,6 @@ public function restore_user_enrolment( $this->enrol_user($instance, $userid, null, $data->timestart, $data->timeend, ENROL_USER_SUSPENDED); } } - - /** - * Recursive get for learner group data with instructors info, to compensate for - * something that the ILIOS API fails to do! - * - * @param string $grouptype Singular noun of the group type, e.g. cohort, learnerGroup. - * @param string $groupid The ID for the corresponding group type, e.g. cohort id, learner group id. - * - * @return mixed Returned by the ILIOS api in addition of populating - * the instructor array with correct ids, which is to - * iterate into offerings and ilmSessions and fetch the - * associated instructors and instructor groups. Should - * also iterate into subgroups. - * @throws Exception - */ - public function get_group_data($grouptype, $groupid) { - $apiclient = $this->get_api_client(); - $accesstoken = $this->get_api_access_token(); - // Ilios API uses a plural noun, append an 's'. - $group = $apiclient->get_by_id($accesstoken, $grouptype.'s', $groupid ); - - if ($group && $grouptype === 'learnerGroup') { - $group->instructors = $this->get_instructor_ids_from_group($grouptype, $groupid); - asort($group->instructors); - } - - return $group; - } - - /** - * Retrieves a list instructors for a given type of group (learner group or instructor group) and given group ID. - - * @param string $grouptype The group type (either 'instructorgroup' or 'learnergroup'). - * @param string $groupid The group ID. - * @return array A list of user IDs. - * @throws moodle_exception - */ - private function get_instructor_ids_from_group($grouptype, $groupid): array { - $apiclient = $this->get_api_client(); - $accesstoken = $this->get_api_access_token(); - - // Ilios API uses a plural noun, append an 's'. - $group = $apiclient->get_by_id($accesstoken, $grouptype.'s', $groupid); - - $instructorgroupids = []; - $instructorids = []; - - // Get instructors/instructor-groups from the offerings that this learner group is being taught in. - if (!empty($group->offerings)) { - $offerings = $apiclient->get_by_ids($accesstoken, 'offerings', $group->offerings); - - foreach ($offerings as $offering) { - if (empty($offering->instructors) && empty($offering->instructorGroups)) { - // No instructor AND no instructor groups have been set for this offering. - // Fall back to the default instructors/instructor-groups defined for the learner group. - $instructorids = array_merge($instructorids, $group->instructors); - $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); - } else { - // If there are instructors and/or instructor-groups set on the offering, then use these. - $instructorids = array_merge($instructorids, $offering->instructors); - $instructorgroupids = array_merge($instructorgroupids, $offering->instructorGroups); - } - } - } - - // Get instructors/instructor-groups from the ilm sessions that this learner group is being taught in. - // This is a rinse/repeat from offerings-related code above. - if (!empty($group->ilmSessions)) { - $ilms = $apiclient->get_by_ids($accesstoken, 'ilmSessions', $group->ilmSessions); - - foreach ($ilms as $ilm) { - if (empty($ilm->instructors) && empty($ilm->instructorGroups)) { - // No instructor AND no instructor groups have been set for this offering. - // Fall back to the default instructors/instructor-groups defined for the learner group. - $instructorids = array_merge($instructorids, $group->instructors); - $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); - } else { - // If there are instructors and/or instructor-groups set on the offering, then use these. - $instructorids = array_merge($instructorids, $ilm->instructors); - $instructorgroupids = array_merge($instructorgroupids, $ilm->instructorGroups); - } - } - } - - // Get instructors from sub-learner-groups. - if (!empty($group->children)) { - foreach ($group->children as $subgroupid) { - $instructorids = array_merge( - $instructorids, - $this->get_instructor_ids_from_group('learnerGroup', $subgroupid) - ); - // We don't care about instructor groups here, - // we will merge instructor groups into the $instructorIds array later. - } - } - - // Next, get the ids of all instructors from the instructor-groups that we determined as relevant earlier. - // But first let's de-dupe them. - $instructorgroupids = array_unique($instructorgroupids); - if (!empty($instructorgroupids)) { - $instructorgroups = $apiclient->get_by_ids($accesstoken, 'instructorGroups', $instructorgroupids); - foreach ($instructorgroups as $instructorgroup) { - $instructorids = array_merge($instructorids, $instructorgroup->users); - } - } - - // Finally, we retrieve all the users that were identified as relevant instructors earlier. - $instructorids = array_unique($instructorids); - - return $instructorids; - } } /** diff --git a/tests/ilios_test.php b/tests/ilios_test.php index de509a4..29a1320 100644 --- a/tests/ilios_test.php +++ b/tests/ilios_test.php @@ -28,6 +28,7 @@ use advanced_testcase; use core\di; use core\http_client; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; @@ -60,8 +61,8 @@ public function test_get_schools(): void { $handlerstack = HandlerStack::create(new MockHandler([ new Response(200, [], json_encode([ 'schools' => [ - ['id' => 1, 'title' => 'Medicine'], - ['id' => 2, 'title' => 'Pharmacy'], + ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], + ['id' => 2, 'title' => 'Pharmacy', 'programs' => ['3', '5']], ], ])), ])); @@ -71,37 +72,802 @@ public function test_get_schools(): void { $this->assertCount(2, $schools); $this->assertEquals(1, $schools[0]->id); $this->assertEquals('Medicine', $schools[0]->title); + $this->assertEquals(['2', '4'], $schools[0]->programs); $this->assertEquals(2, $schools[1]->id); $this->assertEquals('Pharmacy', $schools[1]->title); + $this->assertEquals(['3', '5'], $schools[1]->programs); + } + + /** + * Tests the happy path on get_cohorts(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_cohorts(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'title' => 'Class of 2023', + 'programYear' => 1, + 'courses' => ['3'], + 'users' => ['1', '2'], + 'learnerGroups' => ['5', '8'], + ], + [ + 'id' => 2, + 'title' => 'Class of 2024', + 'programYear' => 3, + 'courses' => [], + 'users' => [], + 'learnerGroups' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $cohorts = $ilios->get_cohorts(); + $this->assertCount(2, $cohorts); + $this->assertEquals(1, $cohorts[0]->id); + $this->assertEquals('Class of 2023', $cohorts[0]->title); + $this->assertEquals(1, $cohorts[0]->programYear); + $this->assertEquals(['3'], $cohorts[0]->courses); + $this->assertEquals(['1', '2'], $cohorts[0]->users); + $this->assertEquals(['5', '8'], $cohorts[0]->learnerGroups); + $this->assertEquals(2, $cohorts[1]->id); + $this->assertEquals('Class of 2024', $cohorts[1]->title); + $this->assertEquals(3, $cohorts[1]->programYear); + $this->assertEquals([], $cohorts[1]->courses); + $this->assertEquals([], $cohorts[1]->users); + $this->assertEquals([], $cohorts[1]->learnerGroups); + } + + /** + * Tests the happy path on get_programs(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_programs(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'programs' => [ + [ + 'id' => 1, + 'title' => 'Doctor of Medicine - MD', + 'shortTitle' => 'MD', + 'school' => 1, + 'programYears' => ['1', '2'], + ], + [ + 'id' => 2, + 'title' => 'Doctor of Medicine - Bridges', + 'shortTitle' => 'Bridges', + 'school' => 2, + 'programYears' => ['3'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $programs = $ilios->get_programs(); + $this->assertCount(2, $programs); + $this->assertEquals(1, $programs[0]->id); + $this->assertEquals('Doctor of Medicine - MD', $programs[0]->title); + $this->assertEquals('MD', $programs[0]->shortTitle); + $this->assertEquals(1, $programs[0]->school); + $this->assertEquals(['1', '2'], $programs[0]->programYears); + $this->assertEquals(2, $programs[1]->id); + $this->assertEquals('Doctor of Medicine - Bridges', $programs[1]->title); + $this->assertEquals('Bridges', $programs[1]->shortTitle); + $this->assertEquals(2, $programs[1]->school); + $this->assertEquals(['3'], $programs[1]->programYears); + } + + /** + * Tests the happy path on get_program_years(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_program_years(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'programYears' => [ + [ + 'id' => 1, + 'startYear' => 2023, + 'program' => 1, + 'cohort' => 2, + ], + [ + 'id' => 2, + 'startYear' => 2024, + 'program' => 2, + 'cohort' => 3, + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $programs = $ilios->get_program_years(); + $this->assertCount(2, $programs); + $this->assertEquals(1, $programs[0]->id); + $this->assertEquals(2023, $programs[0]->startYear); + $this->assertEquals(1, $programs[0]->program); + $this->assertEquals(2, $programs[0]->cohort); + $this->assertEquals(2, $programs[1]->id); + $this->assertEquals(2024, $programs[1]->startYear); + $this->assertEquals(2, $programs[1]->program); + $this->assertEquals(3, $programs[1]->cohort); + } + + /** + * Tests the happy path on get_learner_groups(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_learner_groups(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'instructorGroups' => ['3', '4', '5'], + 'instructors' => ['7'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 2, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'instructorGroups' => [], + 'instructors' => [], + 'users' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $learnergroups = $ilios->get_learner_groups(); + $this->assertCount(2, $learnergroups); + $this->assertEquals(1, $learnergroups[0]->id); + $this->assertEquals('Alpha', $learnergroups[0]->title); + $this->assertEquals(1, $learnergroups[0]->cohort); + $this->assertNull($learnergroups[0]->parent); + $this->assertEquals(['2'], $learnergroups[0]->children); + $this->assertEquals(['1', '2'], $learnergroups[0]->ilmSessions); + $this->assertEquals(['5', '6'], $learnergroups[0]->offerings); + $this->assertEquals(['3', '4', '5'], $learnergroups[0]->instructorGroups); + $this->assertEquals(['7'], $learnergroups[0]->instructors); + $this->assertEquals(['4', '12'], $learnergroups[0]->users); + $this->assertEquals(2, $learnergroups[1]->id); + $this->assertEquals('Beta', $learnergroups[1]->title); + $this->assertEquals(2, $learnergroups[1]->cohort); + $this->assertEquals(1, $learnergroups[1]->parent); + $this->assertEquals([], $learnergroups[1]->children); + $this->assertEquals([], $learnergroups[1]->ilmSessions); + $this->assertEquals([], $learnergroups[1]->offerings); + $this->assertEquals([], $learnergroups[1]->instructorGroups); + $this->assertEquals([], $learnergroups[1]->instructors); + $this->assertEquals([], $learnergroups[1]->users); + } + + /** + * Tests the happy path on get_instructor_groups(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_instructor_groups(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 1, + 'title' => 'Anatomy Lab Instructors', + 'school' => 1, + 'learnerGroups' => ['8', '9'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Clinical Pharmacy Instructors', + 'school' => 2, + 'learnerGroups' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'users' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $instructorgroups = $ilios->get_instructor_groups(); + $this->assertCount(2, $instructorgroups); + $this->assertEquals(1, $instructorgroups[0]->id); + $this->assertEquals('Anatomy Lab Instructors', $instructorgroups[0]->title); + $this->assertEquals(1, $instructorgroups[0]->school); + $this->assertEquals(['8', '9'], $instructorgroups[0]->learnerGroups); + $this->assertEquals(['1', '2'], $instructorgroups[0]->ilmSessions); + $this->assertEquals(['5', '6'], $instructorgroups[0]->offerings); + $this->assertEquals(['4', '12'], $instructorgroups[0]->users); + $this->assertEquals(2, $instructorgroups[1]->id); + $this->assertEquals('Clinical Pharmacy Instructors', $instructorgroups[1]->title); + $this->assertEquals(2, $instructorgroups[1]->school); + $this->assertEquals([], $instructorgroups[1]->learnerGroups); + $this->assertEquals([], $instructorgroups[1]->ilmSessions); + $this->assertEquals([], $instructorgroups[1]->offerings); + $this->assertEquals([], $instructorgroups[1]->users); + } + + /** + * Tests the happy path on get_users(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_offerings(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1', '2'], + 'instructorGroups' => ['2', '4'], + 'learners' => ['8', '9'], + 'instructors' => ['5', '6'], + ], + [ + 'id' => 2, + 'learnerGroups' => [], + 'instructorGroups' => [], + 'learners' => [], + 'instructors' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $offerings = $ilios->get_offerings(); + $this->assertCount(2, $offerings); + $this->assertEquals(1, $offerings[0]->id); + $this->assertEquals(['1', '2'], $offerings[0]->learnerGroups); + $this->assertEquals(['2', '4'], $offerings[0]->instructorGroups); + $this->assertEquals(['8', '9'], $offerings[0]->learners); + $this->assertEquals(['5', '6'], $offerings[0]->instructors); + $this->assertEquals(2, $offerings[1]->id); + $this->assertEquals([], $offerings[1]->learnerGroups); + $this->assertEquals([], $offerings[1]->instructorGroups); + $this->assertEquals([], $offerings[1]->learners); + $this->assertEquals([], $offerings[1]->instructors); + } + + /** + * Tests the happy path on get_users(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_ilms(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1', '2'], + 'instructorGroups' => ['2', '4'], + 'learners' => ['8', '9'], + 'instructors' => ['5', '6'], + ], + [ + 'id' => 2, + 'learnerGroups' => [], + 'instructorGroups' => [], + 'learners' => [], + 'instructors' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $ilms = $ilios->get_ilms(); + $this->assertCount(2, $ilms); + $this->assertEquals(1, $ilms[0]->id); + $this->assertEquals(['1', '2'], $ilms[0]->learnerGroups); + $this->assertEquals(['2', '4'], $ilms[0]->instructorGroups); + $this->assertEquals(['8', '9'], $ilms[0]->learners); + $this->assertEquals(['5', '6'], $ilms[0]->instructors); + $this->assertEquals(2, $ilms[1]->id); + $this->assertEquals([], $ilms[1]->learnerGroups); + $this->assertEquals([], $ilms[1]->instructorGroups); + $this->assertEquals([], $ilms[1]->learners); + $this->assertEquals([], $ilms[1]->instructors); } /** - * Tests the happy path on get_enabled_users_in_school(). + * Tests the happy path on get_users(). * * @return void * @throws GuzzleException * @throws moodle_exception */ - public function test_get_enabled_users_in_school(): void { + public function test_get_users(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ new Response(200, [], json_encode([ 'users' => [ - ['id' => 1, 'campusId' => 'xx00001'], - ['id' => 2, 'campusId' => 'xx00002'], + [ + 'id' => 1, + 'enabled' => true, + 'campusId' => 'xx1000001', + ], + [ + 'id' => 2, + 'enabled' => false, + 'campusId' => 'xx1000002', + ], ], ])), ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); - $users = $ilios->get_enabled_users_in_school(123); + $users = $ilios->get_users(); $this->assertCount(2, $users); $this->assertEquals(1, $users[0]->id); - $this->assertEquals('xx00001', $users[0]->campusId); + $this->assertTrue($users[0]->enabled); + $this->assertEquals('xx1000001', $users[0]->campusId); $this->assertEquals(2, $users[1]->id); - $this->assertEquals('xx00002', $users[1]->campusId); + $this->assertFalse($users[1]->enabled); + $this->assertEquals('xx1000002', $users[1]->campusId); + } + + /** + * Tests retrieving a school from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_school(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $school = $ilios->get_school(1); + $this->assertEquals(1, $school->id); + $this->assertEquals('Medicine', $school->title); + $this->assertEquals(['2', '4'], $school->programs); + } + + /** + * Tests retrieving a school that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_school_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $school = $ilios->get_school(1); + $this->assertNull($school); + } + + /** + * Tests retrieving a cohort from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_cohort(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'title' => 'Class of 2023', + 'programYear' => 1, + 'courses' => ['3'], + 'users' => ['1', '2'], + 'learnerGroups' => ['5', '8'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $cohort = $ilios->get_cohort(1); + $this->assertEquals(1, $cohort->id); + $this->assertEquals('Class of 2023', $cohort->title); + $this->assertEquals(1, $cohort->programYear); + $this->assertEquals(['3'], $cohort->courses); + $this->assertEquals(['1', '2'], $cohort->users); + $this->assertEquals(['5', '8'], $cohort->learnerGroups); + } + + /** + * Tests retrieving a cohort that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_cohort_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $cohort = $ilios->get_cohort(1); + $this->assertNull($cohort); + } + + /** + * Tests retrieving a program from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_program(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'programs' => [ + [ + 'id' => 1, + 'title' => 'Doctor of Medicine - MD', + 'shortTitle' => 'MD', + 'school' => 1, + 'programYears' => ['1', '2'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $program = $ilios->get_program(1); + $this->assertEquals(1, $program->id); + $this->assertEquals('Doctor of Medicine - MD', $program->title); + $this->assertEquals('MD', $program->shortTitle); + $this->assertEquals(1, $program->school); + $this->assertEquals(['1', '2'], $program->programYears); + } + + /** + * Tests retrieving a program that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_program_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $program = $ilios->get_program(1); + $this->assertNull($program); + } + + /** + * Tests retrieving a learner-group from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_learner_group(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'instructorGroups' => ['3', '4', '5'], + 'instructors' => ['7'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 2, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'instructorGroups' => [], + 'instructors' => [], + 'users' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $learnergroup = $ilios->get_learner_group(1); + $this->assertEquals(1, $learnergroup->id); + $this->assertEquals('Alpha', $learnergroup->title); + $this->assertEquals(1, $learnergroup->cohort); + $this->assertNull($learnergroup->parent); + $this->assertEquals(['2'], $learnergroup->children); + $this->assertEquals(['1', '2'], $learnergroup->ilmSessions); + $this->assertEquals(['5', '6'], $learnergroup->offerings); + $this->assertEquals(['3', '4', '5'], $learnergroup->instructorGroups); + $this->assertEquals(['7'], $learnergroup->instructors); + $this->assertEquals(['4', '12'], $learnergroup->users); + } + + /** + * Tests retrieving a learner-group that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_learner_group_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $learnergroup = $ilios->get_learner_group(1); + $this->assertNull($learnergroup); + } + + /** + * Tests retrieving instructors for a given learner-group and its subgroups. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_instructor_ids_from_learner_group(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + // The user ids in the 900 range are users we don't want in the output. + // All other user ids, 1-9 should be in the output of this function. + // Some of these are assigned instructors in various ways, so we can verify that de-duping works. + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2', '3'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['1', '2'], + 'instructorGroups' => ['900'], + 'instructors' => ['900'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1'], + 'instructorGroups' => ['1'], + 'learners' => ['901', '902'], + 'instructors' => [], + ], + [ + 'id' => 2, + 'learnerGroups' => ['1'], + 'instructorGroups' => ['901'], + 'learners' => ['903'], + 'instructors' => ['1'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1'], + 'instructorGroups' => ['1'], + 'learners' => ['901', '902'], + 'instructors' => [], + ], + [ + 'id' => 2, + 'learnerGroups' => ['1'], + 'instructorGroups' => [], + 'learners' => ['903'], + 'instructors' => ['1'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 1, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => ['3'], + 'instructorGroups' => ['2'], + 'instructors' => ['2'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 3, + 'learnerGroups' => ['2'], + 'instructorGroups' => [], + 'learners' => ['904'], + 'instructors' => [], + ], + ], + ])), + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 2, + 'title' => 'Zwei', + 'school' => 1, + 'learnerGroups' => ['2'], + 'ilmSessions' => [], + 'offerings' => [], + 'users' => ['6', '7'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 3, + 'title' => 'Gamma', + 'cohort' => 1, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => ['3'], + 'offerings' => [], + 'instructorGroups' => ['3'], + 'instructors' => ['3'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 3, + 'learnerGroups' => ['2'], + 'instructorGroups' => [], + 'learners' => ['905'], + 'instructors' => [], + ], + ], + ])), + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 3, + 'title' => 'Drei', + 'school' => 1, + 'learnerGroups' => [], + 'ilmSessions' => [], + 'offerings' => ['1'], + 'users' => ['8', '9'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 1, + 'title' => 'Eins', + 'school' => 1, + 'learnerGroups' => [], + 'ilmSessions' => ['1'], + 'offerings' => ['1'], + 'users' => ['4', '5'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $ids = $ilios->get_instructor_ids_from_learner_group(1); + $this->assertEquals(['1', '2', '3', '4', '5', '6', '7', '8', '9'], $ids); } /** @@ -133,6 +899,116 @@ public function test_get(): void { $this->assertEquals('Pharmacy', $data->schools[1]->title); } + /** + * Tests get() with filter- and sorting criteria as input. + * + * @dataProvider get_with_filtering_and_sorting_provider + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @param string $expectedquerystring The expected query string that the given criteria transform into. + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_with_filtering_and_sorting_criteria( + array $filterby, + array $sortby, + string $expectedquerystring): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + + $mockclient = $this->createMock(http_client::class); + $mockclient + ->expects($this->once()) + ->method('get') + ->with( + $this->equalTo('http://ilios.demo/api/v3/geflarkniks' . $expectedquerystring), + $this->anything(), + ) + ->willReturn(new Response(200, [], json_encode(['geflarkniks' => [['doesnt-really' => 'matter']]]))); + + di::set(http_client::class, $mockclient); + $ilios = di::get(ilios::class); + $ilios->get('geflarkniks', $filterby, $sortby); + } + + /** + * Data provider for test_get_with_filtering_and_sorting_criteria(). + * Returns test filter and sorting criteria and their expected transformation into a query string. + * + * @return array[] + */ + public static function get_with_filtering_and_sorting_provider(): array { + return [ + [[], [], ''], + [['foo' => 'bar'], [], '?filters[foo]=bar'], + [[], ['name' => 'DESC'], '?order_by[name]=DESC'], + [ + ['id' => [1, 2], 'school' => 5], + ['title' => 'ASC'], + '?filters[id][]=1&filters[id][]=2&filters[school]=5&order_by[title]=ASC', + ], + ]; + } + + + /** + * Tests retrieving a resource by its ID from the Ilios API. + */ + public function test_get_by_id(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['geflarknik' => [ + ['id' => 1, 'title' => 'whatever'], + ]])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $response = $ilios->get_by_id('does-not-matter-here', 12345); + $this->assertObjectHasProperty('geflarknik', $response); + $this->assertCount(1, $response->geflarknik); + $this->assertEquals('1', $response->geflarknik[0]->id); + $this->assertEquals('whatever', $response->geflarknik[0]->title); + } + + /** + * Tests that get_by_id() raises an exception if Ilios responds with a 404/not-found. + */ + public function test_get_by_id_fails_on_404(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404, []), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('404 Not Found'); + $this->expectExceptionCode(404); + $ilios->get_by_id('does-not-matter-here', 12345, false); + } + + /** + * Tests that get_by_id() returns NULL if Ilios responds with a 404/not-found. + */ + public function test_get_by_id_returns_null_on_404(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404, []), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $response = $ilios->get_by_id('does-not-matter-here', 12345); + $this->assertNull($response); + } + /** * Tests that get() fails if the response cannot be JSON-decoded. * From 08d83f8c15a846c345f4fb0aba3218dd5a2af3f4 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Thu, 26 Sep 2024 11:16:00 -0700 Subject: [PATCH 08/20] rm obsolete dependency on ilios_apiclient plugin. the functionality we got from this dependency has been absorbed into this plugin. fixes typo in release info and bumps version while at it. --- version.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/version.php b/version.php index 03c03a8..51b9c58 100644 --- a/version.php +++ b/version.php @@ -25,12 +25,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024091800; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2024092600; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2024041600; // Requires this Moodle version. $plugin->component = 'enrol_ilios'; // Full name of the plugin (used for diagnostics). -$plugin->release = 'v4.4=-c1'; +$plugin->release = 'v4.4-rc1'; $plugin->supported = [404, 404]; $plugin->maturity = MATURITY_RC; -$plugin->dependencies = [ - 'local_iliosapiclient' => 2024061000, -]; From 4616810823632df9192e67ad0123f623180056c0 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Thu, 26 Sep 2024 11:36:08 -0700 Subject: [PATCH 09/20] updates copyright annotations in source files. i'm dropping the year info from the copyright. it's unnecessary. this code has always, and will always, "belong to" the regents. --- ajax.php | 2 +- classes/task/ilios_sync_task.php | 4 ++-- db/access.php | 2 +- db/tasks.php | 2 +- db/uninstall.php | 2 +- edit.php | 2 +- edit_form.php | 4 ++-- lang/en/enrol_ilios.php | 2 +- lang/en_us/enrol_ilios.php | 2 +- lib.php | 2 +- settings.php | 2 +- version.php | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ajax.php b/ajax.php index 6976244..170d72d 100644 --- a/ajax.php +++ b/ajax.php @@ -22,7 +22,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/classes/task/ilios_sync_task.php b/classes/task/ilios_sync_task.php index b223d52..b73feb4 100644 --- a/classes/task/ilios_sync_task.php +++ b/classes/task/ilios_sync_task.php @@ -18,7 +18,7 @@ * Scheduled task for processing Ilios enrolments. * * @package enrol_ilios - * @copyright 2018 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -31,7 +31,7 @@ * Simple task to run sync enrolments. * * @package enrol_ilios - * @copyright 2018 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class ilios_sync_task extends scheduled_task { diff --git a/db/access.php b/db/access.php index 2cea578..29482bb 100644 --- a/db/access.php +++ b/db/access.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/db/tasks.php b/db/tasks.php index d464419..277b753 100644 --- a/db/tasks.php +++ b/db/tasks.php @@ -18,7 +18,7 @@ * Definition of Ilios enrolment scheduled tasks. * * @package enrol_ilios - * @copyright 2018 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/db/uninstall.php b/db/uninstall.php index 4af99f8..cf682f6 100644 --- a/db/uninstall.php +++ b/db/uninstall.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/edit.php b/edit.php index 6a670fc..e3d580d 100644 --- a/edit.php +++ b/edit.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/edit_form.php b/edit_form.php index 72b853f..31e8b88 100644 --- a/edit_form.php +++ b/edit_form.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -36,7 +36,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class enrol_ilios_edit_form extends moodleform { diff --git a/lang/en/enrol_ilios.php b/lang/en/enrol_ilios.php index f741967..e457001 100644 --- a/lang/en/enrol_ilios.php +++ b/lang/en/enrol_ilios.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/lang/en_us/enrol_ilios.php b/lang/en_us/enrol_ilios.php index a7e93ec..232db70 100644 --- a/lang/en_us/enrol_ilios.php +++ b/lang/en_us/enrol_ilios.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/lib.php b/lib.php index 820a823..46e306d 100644 --- a/lib.php +++ b/lib.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/settings.php b/settings.php index fbb6e55..89d1e1c 100644 --- a/settings.php +++ b/settings.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/version.php b/version.php index 51b9c58..a0a00cd 100644 --- a/version.php +++ b/version.php @@ -19,7 +19,7 @@ * * @package enrol_ilios * @author Carson Tam - * @copyright 2017 The Regents of the University of California + * @copyright The Regents of the University of California * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ From e159f62718804b0a8f8c5ad1f6246987dff419a9 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Mon, 30 Sep 2024 13:53:03 -0700 Subject: [PATCH 10/20] adds test coverage for enrolment workflows. --- tests/lib_test.php | 886 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 886 insertions(+) create mode 100644 tests/lib_test.php diff --git a/tests/lib_test.php b/tests/lib_test.php new file mode 100644 index 0000000..2e653a0 --- /dev/null +++ b/tests/lib_test.php @@ -0,0 +1,886 @@ +. + +/** + * Ilios enrolment tests. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace enrol_ilios; + +use context_course; +use core\di; +use core\http_client; +use enrol_ilios\tests\helper; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use null_progress_trace; +use Psr\Http\Message\RequestInterface; + +/** + * Ilios enrolment tests. + * + * @category test + * @package enrol_ilios + * @copyright The Regents of the University of California + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \enrol_ilios_plugin + */ +final class lib_test extends \advanced_testcase { + + + /** + * Tests the enrolment of Ilios cohort members into a Moodle course. + */ + public function test_enrolment_from_ilios_cohort_members(): void { + global $CFG, $DB; + $this->resetAfterTest(); + + // Configure the Ilios API client. + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + + // Mock out the responses from the Ilios API. + $handlerstack = HandlerStack::create(new MockHandler([ + function(RequestInterface $request) { + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'users' => ['2', '3', '4', '5'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=2&filters[id][]=3&filters[id][]=4&filters[id][]=5', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [ + [ + 'id' => 2, + 'campusId' => 'xx1000002', + 'enabled' => true, + ], + [ + 'id' => 3, + 'campusId' => 'xx1000003', + 'enabled' => true, + ], + [ + 'id' => 4, + 'campusId' => 'xx1000004', + 'enabled' => false, // Disabled user account - should result in user unenrolment. + ], + [ + 'id' => 5, + 'campusId' => 'xx1000005', // Not currently enrolled - should result in new user enrolment. + 'enabled' => true, + ], + ], + ])); + }, + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Create a course and users, enrol some students. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + + $user1 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000001']); + $user2 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000002']); + $user3 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000003']); + $user4 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000004']); + $user5 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000005']); + + $this->assertEquals(0, $DB->count_records('enrol', ['enrol' => 'ilios'])); + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(0, $DB->count_records('user_enrolments')); + + // Instantiate an enrolment instance that targets cohort members in Ilios. + $plugin->add_instance($course, [ + 'customint1' => 1, // Ilios cohort ID. + 'customint2' => 0, // Ilios learners enrolment. + 'customchar1' => 'cohort', // Enrol from cohort. + 'roleid' => $studentrole->id, + ] + ); + $this->assertEquals(1, $DB->count_records('enrol', ['enrol' => 'ilios'])); + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*', MUST_EXIST); + $this->assertEquals($studentrole->id, $instance->roleid); + $this->assertEquals(1, $instance->customint1); + $this->assertEquals(0, $instance->customint2); + $this->assertEquals('cohort', $instance->customchar1); + + // Enable the enrolment method. + $CFG->enrol_plugins_enabled = 'ilios'; + + // Enroll users 1-4, but not 5. + $plugin->enrol_user($instance, $user1->id, $studentrole->id); + $plugin->enrol_user($instance, $user2->id, $studentrole->id); + $plugin->enrol_user($instance, $user3->id, $studentrole->id); + $plugin->enrol_user($instance, $user4->id, $studentrole->id); + + // Check user enrolments pre-sync. + $this->assertEquals( + 4, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // Users 1 - 4 are actively enrolled in the course. + foreach ([$user1, $user2, $user3, $user4] as $user) { + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST + ) + ); + $userenrolment = $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + ], + strictness: MUST_EXIST + ); + $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); + } + + // Verify that user 5 is not enrolled. + $this->assertEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user5->id, + 'contextid' => $context->id, + ] + ) + ); + $this->assertEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user5->id, + ] + ) + ); + + // Run enrolment sync. + $trace = new null_progress_trace(); + $plugin->sync($trace, null); + + // Check user enrolments post-sync. + $this->assertEquals( + 3, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // User 2, 3, and now 5, are actively enrolled as students in the course. + foreach ([$user2, $user3, $user5] as $user) { + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST + ) + ); + $userenrolment = $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + ], + strictness: MUST_EXIST + ); + $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); + } + // Verify that user1 has been fully unenrolled and has no role assigment in the course. + $this->assertEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user1->id, + 'contextid' => $context->id, + ] + ) + ); + $this->assertEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user1->id, + ] + ) + ); + // User 4 is still enrolled in the course, but their enrolment status suspended and their student assignment + // in the course has been removed. + $this->assertEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user4->id, + 'contextid' => $context->id, + ] + ) + ); + $userenrolment = $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user4->id, + ], + strictness: MUST_EXIST + ); + $this->assertEquals(ENROL_USER_SUSPENDED, $userenrolment->status); + } + + /** + * Tests the enrolment of Ilios learner-group members into a Moodle course. + */ + public function test_enrolment_from_ilios_learner_group_members(): void { + global $CFG, $DB; + $this->resetAfterTest(); + + // Configure the Ilios API client. + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + + // Mock out the responses from the Ilios API. + $handlerstack = HandlerStack::create(new MockHandler([ + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'users' => ['2', '3', '4', '5'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=2&filters[id][]=3&filters[id][]=4&filters[id][]=5', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [ + [ + 'id' => 2, + 'campusId' => 'xx1000002', + 'enabled' => true, + ], + [ + 'id' => 3, + 'campusId' => 'xx1000003', + 'enabled' => true, + ], + [ + 'id' => 4, + 'campusId' => 'xx1000004', + 'enabled' => false, // Disabled user account - should result in user unenrolment. + ], + [ + 'id' => 5, + 'campusId' => 'xx1000005', // Not currently enrolled - should result in new user enrolment. + 'enabled' => true, + ], + ], + ])); + }, + + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Create a course and users, enrol some students. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + + $user1 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000001']); + $user2 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000002']); + $user3 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000003']); + $user4 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000004']); + $user5 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000005']); + + $this->assertEquals(0, $DB->count_records('enrol', ['enrol' => 'ilios'])); + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(0, $DB->count_records('user_enrolments')); + + // Instantiate an enrolment instance that targets cohort members in Ilios. + $plugin->add_instance($course, [ + 'customint1' => 1, // Ilios learner-group ID. + 'customint2' => 0, // Ilios learners enrolment. + 'customchar1' => 'learnerGroup', // Enrol from learner-group. + 'roleid' => $studentrole->id, + ] + ); + $this->assertEquals(1, $DB->count_records('enrol', ['enrol' => 'ilios'])); + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*', MUST_EXIST); + $this->assertEquals($studentrole->id, $instance->roleid); + $this->assertEquals(1, $instance->customint1); + $this->assertEquals(0, $instance->customint2); + $this->assertEquals('learnerGroup', $instance->customchar1); + + // Enable the enrolment method. + $CFG->enrol_plugins_enabled = 'ilios'; + + // Enroll users 1-4, but not 5. + $plugin->enrol_user($instance, $user1->id, $studentrole->id); + $plugin->enrol_user($instance, $user2->id, $studentrole->id); + $plugin->enrol_user($instance, $user3->id, $studentrole->id); + $plugin->enrol_user($instance, $user4->id, $studentrole->id); + + // Check user enrolments pre-sync. + $this->assertEquals( + 4, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // Users 1 - 4 are actively enrolled in the course. + foreach ([$user1, $user2, $user3, $user4] as $user) { + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST + ) + ); + $userenrolment = $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + ], + strictness: MUST_EXIST + ); + $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); + } + + // Verify that user 5 is not enrolled. + $this->assertEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user5->id, + 'contextid' => $context->id, + ] + ) + ); + $this->assertEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user5->id, + ] + ) + ); + + // Run enrolment sync. + $trace = new null_progress_trace(); + $plugin->sync($trace, null); + + // Check user enrolments post-sync. + $this->assertEquals( + 3, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // User 2, 3, and now 5, are actively enrolled as students in the course. + foreach ([$user2, $user3, $user5] as $user) { + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST + ) + ); + $userenrolment = $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + ], + strictness: MUST_EXIST + ); + $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); + } + // Verify that user1 has been fully unenrolled and has no role assigment in the course. + $this->assertEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user1->id, + 'contextid' => $context->id, + ] + ) + ); + $this->assertEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user1->id, + ] + ) + ); + // User 4 is still enrolled in the course, but their enrolment status suspended and their student assignment + // in the course has been removed. + $this->assertEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user4->id, + 'contextid' => $context->id, + ] + ) + ); + $userenrolment = $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user4->id, + ], + strictness: MUST_EXIST + ); + $this->assertEquals(ENROL_USER_SUSPENDED, $userenrolment->status); + } + + /** + * Tests the enrolment of instructors to an Ilios learner-group (and its subgroups) into a Moodle course. + */ + public function test_enrolment_from_ilios_learner_group_instructors(): void { + global $CFG, $DB; + $this->resetAfterTest(); + + // Configure the Ilios API client. + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + + // Mock out the responses from the Ilios API. + $handlerstack = HandlerStack::create(new MockHandler([ + function(RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'children' => ['2', '3'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['1', '2'], + 'instructorGroups' => [], + 'instructors' => [], + ], + ], + ])); + }, + // We're querying the entry-point learner group again, for no good reason. + // Todo: Eliminate this duplication from the enrolment workflow [ST 2024/09/30]. + function(RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'children' => ['2', '3'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['1', '2'], + 'instructorGroups' => [], + 'instructors' => [], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/offerings', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1&filters[id][]=2', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 1, + 'instructors' => [], + 'instructorGroups' => ['1'], + 'learners' => [], + 'learnerGroups' => ['1'], + ], + [ + 'id' => 2, + 'instructors' => ['1'], + 'instructorGroups' => [], + 'learners' => [], + 'learnerGroups' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/ilmsessions', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1&filters[id][]=2', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 1, + 'instructors' => [], + 'instructorGroups' => ['1'], + 'learners' => [], + 'learnerGroups' => ['1'], + ], + [ + 'id' => 2, + 'instructors' => ['1'], + 'instructorGroups' => [], + 'learners' => [], + 'learnerGroups' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/2', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 2, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => ['3'], + 'instructors' => ['2'], + 'instructorGroups' => ['2'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/offerings', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=3', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 3, + 'instructors' => [], + 'instructorGroups' => [], + 'learners' => [], + 'learnerGroups' => ['2'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/instructorgroups', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=2', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 2, + 'users' => ['6', '7'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/3', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 3, + 'children' => [], + 'ilmSessions' => ['3'], + 'offerings' => [], + 'instructors' => ['3'], + 'instructorGroups' => ['3'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/ilmsessions', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=3', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 3, + 'instructors' => [], + 'instructorGroups' => [], + 'learners' => [], + 'learnerGroups' => [], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/instructorgroups', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=3', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 3, + 'users' => ['8', '9'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/instructorgroups', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=1', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 1, + 'users' => ['4', '5'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1&filters[id][]=2&filters[id][]=3&filters[id][]=4' + . '&filters[id][]=5&filters[id][]=6&filters[id][]=7&filters[id][]=8&filters[id][]=9', + urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'users' => array_map( + fn ($i) => ['id' => $i, 'campusId' => 'xx100000'. $i, 'enabled' => true ], + range(1, 9) + ), + ])); + }, + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Create a course and users, enrol some students. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + + $users = array_map( + fn ($i) => $this->getDataGenerator()->create_user(['idnumber' => 'xx100000'. $i]), + range(1, 9) + ); + + $this->assertEquals(0, $DB->count_records('enrol', ['enrol' => 'ilios'])); + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(0, $DB->count_records('user_enrolments')); + + // Instantiate an enrolment instance that targets cohort members in Ilios. + $plugin->add_instance($course, [ + 'customint1' => 1, // Ilios learner-group ID. + 'customint2' => 1, // Ilios Instructors enrolment. + 'customchar1' => 'learnerGroup', // Enrol from learner-group. + 'roleid' => $studentrole->id, + ] + ); + $this->assertEquals(1, $DB->count_records('enrol', ['enrol' => 'ilios'])); + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*', MUST_EXIST); + $this->assertEquals($studentrole->id, $instance->roleid); + $this->assertEquals(1, $instance->customint1); + $this->assertEquals(1, $instance->customint2); + $this->assertEquals('learnerGroup', $instance->customchar1); + + // Enable the enrolment method. + $CFG->enrol_plugins_enabled = 'ilios'; + + // Check user enrolments pre-sync. + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(0, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // Run enrolment sync. + $trace = new null_progress_trace(); + $plugin->sync($trace, null); + + // Check user enrolments post-sync. + $this->assertEquals( + 9, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(9, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // Check that all users have been enrolled. + foreach ($users as $user) { + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST + ) + ); + $userenrolment = $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + ], + strictness: MUST_EXIST + ); + $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); + } + } +} From 96562e4ccc5246e77732827afc5c95861a35f813 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 1 Oct 2024 10:49:33 -0700 Subject: [PATCH 11/20] adds unenrolment notification back in. --- lib.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib.php b/lib.php index 46e306d..2aa6dcf 100644 --- a/lib.php +++ b/lib.php @@ -416,6 +416,16 @@ public function sync($trace, $courseid = null): int { $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); } + // Unenrol as necessary. + $trace->output( + "Unenrolling users from Course ID " + . $instance->courseid." with Role ID " + . $instance->roleid + . " that no longer associate with Ilios Sync ID " + . $instance->id + . "." + ); + $sql = "SELECT ue.* FROM {user_enrolments} ue WHERE ue.enrolid = $instance->id"; From 06d54566719abc14269a9ac247b94fae54c7144a Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 1 Oct 2024 10:53:03 -0700 Subject: [PATCH 12/20] corrects closing of users loop. see https://github.com/ilios/moodle-enrol-ilios/issues/50 this addresses the regression outline in --- lib.php | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/lib.php b/lib.php index 2aa6dcf..19f5b4d 100644 --- a/lib.php +++ b/lib.php @@ -413,34 +413,19 @@ public function sync($trace, $courseid = null): int { . " via Ilios $synctype $syncid" , 1 ); - $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); - } - - // Unenrol as necessary. - $trace->output( - "Unenrolling users from Course ID " - . $instance->courseid." with Role ID " - . $instance->roleid - . " that no longer associate with Ilios Sync ID " - . $instance->id - . "." - ); - - $sql = "SELECT ue.* - FROM {user_enrolments} ue - WHERE ue.enrolid = $instance->id"; - - if (!empty($enrolleduserids)) { - $sql .= " AND ue.userid NOT IN ( ".implode(",", $enrolleduserids)." )"; - } - - $rs = $DB->get_recordset_sql($sql); - foreach ($rs as $ue) { - if ($unenrolaction == ENROL_EXT_REMOVED_UNENROL) { - // Remove enrolment together with group membership, grades, preferences, etc. - $this->unenrol_user($instance, $ue->userid); + } else { // Would be ENROL_EXT_REMOVED_SUSPENDNOROLES. + // Just disable and ignore any changes. + if ($ue->status != ENROL_USER_SUSPENDED) { + $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); + $context = context_course::instance($instance->courseid); + role_unassign_all([ + 'userid' => $ue->userid, + 'contextid' => $context->id, + 'component' => 'enrol_ilios', + 'itemid' => $instance->id, + ]); $trace->output( - "suspending and unsassigning all roles: userid " + "suspending and unassigning all roles: userid " . $ue->userid . " ==> courseid " . $instance->courseid From c07d8ec63fb4276f2876efb0e04c9c31f9c2468d Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 1 Oct 2024 17:18:41 -0700 Subject: [PATCH 13/20] put output to Ilios API (http requests) under test coverage. cleanup of test vectors while at it. --- tests/ilios_test.php | 837 ++++++++++++++++++++++++------------------- 1 file changed, 471 insertions(+), 366 deletions(-) diff --git a/tests/ilios_test.php b/tests/ilios_test.php index 29a1320..b060ae3 100644 --- a/tests/ilios_test.php +++ b/tests/ilios_test.php @@ -35,6 +35,7 @@ use GuzzleHttp\Psr7\Response; use moodle_exception; use enrol_ilios\tests\helper; +use Psr\Http\Message\RequestInterface; /** * Tests the Ilios API client. @@ -58,13 +59,17 @@ public function test_get_schools(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'schools' => [ - ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], - ['id' => 2, 'title' => 'Pharmacy', 'programs' => ['3', '5']], - ], - ])), + function(RequestInterface $request) { + $this->assertEquals('/api/v3/schools', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], + ['id' => 2, 'title' => 'Pharmacy', 'programs' => ['3', '5']], + ], + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -89,27 +94,31 @@ public function test_get_cohorts(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'cohorts' => [ - [ - 'id' => 1, - 'title' => 'Class of 2023', - 'programYear' => 1, - 'courses' => ['3'], - 'users' => ['1', '2'], - 'learnerGroups' => ['5', '8'], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/cohorts', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'title' => 'Class of 2023', + 'programYear' => 1, + 'courses' => ['3'], + 'users' => ['1', '2'], + 'learnerGroups' => ['5', '8'], + ], + [ + 'id' => 2, + 'title' => 'Class of 2024', + 'programYear' => 3, + 'courses' => [], + 'users' => [], + 'learnerGroups' => [], + ], ], - [ - 'id' => 2, - 'title' => 'Class of 2024', - 'programYear' => 3, - 'courses' => [], - 'users' => [], - 'learnerGroups' => [], - ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -140,25 +149,30 @@ public function test_get_programs(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'programs' => [ - [ - 'id' => 1, - 'title' => 'Doctor of Medicine - MD', - 'shortTitle' => 'MD', - 'school' => 1, - 'programYears' => ['1', '2'], - ], - [ - 'id' => 2, - 'title' => 'Doctor of Medicine - Bridges', - 'shortTitle' => 'Bridges', - 'school' => 2, - 'programYears' => ['3'], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/programs', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'programs' => [ + [ + 'id' => 1, + 'title' => 'Doctor of Medicine - MD', + 'shortTitle' => 'MD', + 'school' => 1, + 'programYears' => ['1', '2'], + ], + [ + 'id' => 2, + 'title' => 'Doctor of Medicine - Bridges', + 'shortTitle' => 'Bridges', + 'school' => 2, + 'programYears' => ['3'], + ], ], - ], - ])), + ])); + }, + ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -187,23 +201,27 @@ public function test_get_program_years(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'programYears' => [ - [ - 'id' => 1, - 'startYear' => 2023, - 'program' => 1, - 'cohort' => 2, - ], - [ - 'id' => 2, - 'startYear' => 2024, - 'program' => 2, - 'cohort' => 3, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/programyears', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'programYears' => [ + [ + 'id' => 1, + 'startYear' => 2023, + 'program' => 1, + 'cohort' => 2, + ], + [ + 'id' => 2, + 'startYear' => 2024, + 'program' => 2, + 'cohort' => 3, + ], ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -230,35 +248,39 @@ public function test_get_learner_groups(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'learnerGroups' => [ - [ - 'id' => 1, - 'title' => 'Alpha', - 'cohort' => 1, - 'parent' => null, - 'children' => ['2'], - 'ilmSessions' => ['1', '2'], - 'offerings' => ['5', '6'], - 'instructorGroups' => ['3', '4', '5'], - 'instructors' => ['7'], - 'users' => ['4', '12'], - ], - [ - 'id' => 2, - 'title' => 'Beta', - 'cohort' => 2, - 'parent' => 1, - 'children' => [], - 'ilmSessions' => [], - 'offerings' => [], - 'instructorGroups' => [], - 'instructors' => [], - 'users' => [], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'instructorGroups' => ['3', '4', '5'], + 'instructors' => ['7'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 2, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'instructorGroups' => [], + 'instructors' => [], + 'users' => [], + ], ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -297,29 +319,33 @@ public function test_get_instructor_groups(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'instructorGroups' => [ - [ - 'id' => 1, - 'title' => 'Anatomy Lab Instructors', - 'school' => 1, - 'learnerGroups' => ['8', '9'], - 'ilmSessions' => ['1', '2'], - 'offerings' => ['5', '6'], - 'users' => ['4', '12'], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/instructorgroups', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 1, + 'title' => 'Anatomy Lab Instructors', + 'school' => 1, + 'learnerGroups' => ['8', '9'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Clinical Pharmacy Instructors', + 'school' => 2, + 'learnerGroups' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'users' => [], + ], ], - [ - 'id' => 2, - 'title' => 'Clinical Pharmacy Instructors', - 'school' => 2, - 'learnerGroups' => [], - 'ilmSessions' => [], - 'offerings' => [], - 'users' => [], - ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -352,25 +378,29 @@ public function test_get_offerings(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'offerings' => [ - [ - 'id' => 1, - 'learnerGroups' => ['1', '2'], - 'instructorGroups' => ['2', '4'], - 'learners' => ['8', '9'], - 'instructors' => ['5', '6'], - ], - [ - 'id' => 2, - 'learnerGroups' => [], - 'instructorGroups' => [], - 'learners' => [], - 'instructors' => [], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/offerings', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1', '2'], + 'instructorGroups' => ['2', '4'], + 'learners' => ['8', '9'], + 'instructors' => ['5', '6'], + ], + [ + 'id' => 2, + 'learnerGroups' => [], + 'instructorGroups' => [], + 'learners' => [], + 'instructors' => [], + ], ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -399,25 +429,29 @@ public function test_get_ilms(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'ilmSessions' => [ - [ - 'id' => 1, - 'learnerGroups' => ['1', '2'], - 'instructorGroups' => ['2', '4'], - 'learners' => ['8', '9'], - 'instructors' => ['5', '6'], - ], - [ - 'id' => 2, - 'learnerGroups' => [], - 'instructorGroups' => [], - 'learners' => [], - 'instructors' => [], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/ilmsessions', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1', '2'], + 'instructorGroups' => ['2', '4'], + 'learners' => ['8', '9'], + 'instructors' => ['5', '6'], + ], + [ + 'id' => 2, + 'learnerGroups' => [], + 'instructorGroups' => [], + 'learners' => [], + 'instructors' => [], + ], ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -446,21 +480,25 @@ public function test_get_users(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'users' => [ - [ - 'id' => 1, - 'enabled' => true, - 'campusId' => 'xx1000001', + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'users' => [ + [ + 'id' => 1, + 'enabled' => true, + 'campusId' => 'xx1000001', + ], + [ + 'id' => 2, + 'enabled' => false, + 'campusId' => 'xx1000002', + ], ], - [ - 'id' => 2, - 'enabled' => false, - 'campusId' => 'xx1000002', - ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -485,12 +523,16 @@ public function test_get_school(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'schools' => [ - ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], - ], - ])), + function (RequestInterface $request) { + $this->assertEquals('/api/v3/schools/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], + ], + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -531,19 +573,23 @@ public function test_get_cohort(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'cohorts' => [ - [ - 'id' => 1, - 'title' => 'Class of 2023', - 'programYear' => 1, - 'courses' => ['3'], - 'users' => ['1', '2'], - 'learnerGroups' => ['5', '8'], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'title' => 'Class of 2023', + 'programYear' => 1, + 'courses' => ['3'], + 'users' => ['1', '2'], + 'learnerGroups' => ['5', '8'], + ], ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -587,18 +633,22 @@ public function test_get_program(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'programs' => [ - [ - 'id' => 1, - 'title' => 'Doctor of Medicine - MD', - 'shortTitle' => 'MD', - 'school' => 1, - 'programYears' => ['1', '2'], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/programs/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'programs' => [ + [ + 'id' => 1, + 'title' => 'Doctor of Medicine - MD', + 'shortTitle' => 'MD', + 'school' => 1, + 'programYears' => ['1', '2'], + ], ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -641,35 +691,40 @@ public function test_get_learner_group(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'learnerGroups' => [ - [ - 'id' => 1, - 'title' => 'Alpha', - 'cohort' => 1, - 'parent' => null, - 'children' => ['2'], - 'ilmSessions' => ['1', '2'], - 'offerings' => ['5', '6'], - 'instructorGroups' => ['3', '4', '5'], - 'instructors' => ['7'], - 'users' => ['4', '12'], - ], - [ - 'id' => 2, - 'title' => 'Beta', - 'cohort' => 2, - 'parent' => 1, - 'children' => [], - 'ilmSessions' => [], - 'offerings' => [], - 'instructorGroups' => [], - 'instructors' => [], - 'users' => [], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'instructorGroups' => ['3', '4', '5'], + 'instructors' => ['7'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 2, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'instructorGroups' => [], + 'instructors' => [], + 'users' => [], + ], ], - ], - ])), + ])); + }, + ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -717,152 +772,191 @@ public function test_get_instructor_ids_from_learner_group(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); // The user ids in the 900 range are users we don't want in the output. // All other user ids, 1-9 should be in the output of this function. // Some of these are assigned instructors in various ways, so we can verify that de-duping works. $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'learnerGroups' => [ - [ - 'id' => 1, - 'title' => 'Alpha', - 'cohort' => 1, - 'parent' => null, - 'children' => ['2', '3'], - 'ilmSessions' => ['1', '2'], - 'offerings' => ['1', '2'], - 'instructorGroups' => ['900'], - 'instructors' => ['900'], - ], - ], - ])), - new Response(200, [], json_encode([ - 'offerings' => [ - [ - 'id' => 1, - 'learnerGroups' => ['1'], - 'instructorGroups' => ['1'], - 'learners' => ['901', '902'], - 'instructors' => [], - ], - [ - 'id' => 2, - 'learnerGroups' => ['1'], - 'instructorGroups' => ['901'], - 'learners' => ['903'], - 'instructors' => ['1'], + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2', '3'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['1', '2'], + 'instructors' => ['900'], + 'instructorGroups' => ['900'], + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'ilmSessions' => [ - [ - 'id' => 1, - 'learnerGroups' => ['1'], - 'instructorGroups' => ['1'], - 'learners' => ['901', '902'], - 'instructors' => [], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/offerings', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=1&filters[id][]=2', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 1, + 'instructors' => [], + 'instructorGroups' => ['1'], + 'learners' => ['901', '902'], + 'learnerGroups' => ['1'], + ], + [ + 'id' => 2, + 'instructors' => ['1'], + 'instructorGroups' => [], + 'learners' => ['903'], + 'learnerGroups' => ['1'], + ], ], - [ - 'id' => 2, - 'learnerGroups' => ['1'], - 'instructorGroups' => [], - 'learners' => ['903'], - 'instructors' => ['1'], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/ilmsessions', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=1&filters[id][]=2', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 1, + 'instructors' => [], + 'instructorGroups' => ['1'], + 'learners' => ['901', '902'], + 'learnerGroups' => ['1'], + ], + [ + 'id' => 2, + 'instructors' => ['1'], + 'instructorGroups' => [], + 'learners' => ['903'], + 'learnerGroups' => ['1'], + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'learnerGroups' => [ - [ - 'id' => 2, - 'title' => 'Beta', - 'cohort' => 1, - 'parent' => 1, - 'children' => [], - 'ilmSessions' => [], - 'offerings' => ['3'], - 'instructorGroups' => ['2'], - 'instructors' => ['2'], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/2', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 1, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => ['3'], + 'instructors' => ['2'], + 'instructorGroups' => ['2'], + + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'offerings' => [ - [ - 'id' => 3, - 'learnerGroups' => ['2'], - 'instructorGroups' => [], - 'learners' => ['904'], - 'instructors' => [], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/offerings', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=3', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 3, + 'instructors' => [], + 'instructorGroups' => [], + 'learners' => ['904'], + 'learnerGroups' => ['2'], + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'instructorGroups' => [ - [ - 'id' => 2, - 'title' => 'Zwei', - 'school' => 1, - 'learnerGroups' => ['2'], - 'ilmSessions' => [], - 'offerings' => [], - 'users' => ['6', '7'], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/instructorgroups', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=2', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 2, + 'title' => 'Zwei', + 'school' => 1, + 'learnerGroups' => ['2'], + 'ilmSessions' => [], + 'offerings' => [], + 'users' => ['6', '7'], + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'learnerGroups' => [ - [ - 'id' => 3, - 'title' => 'Gamma', - 'cohort' => 1, - 'parent' => 1, - 'children' => [], - 'ilmSessions' => ['3'], - 'offerings' => [], - 'instructorGroups' => ['3'], - 'instructors' => ['3'], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/learnergroups/3', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 3, + 'title' => 'Gamma', + 'cohort' => 1, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => ['3'], + 'offerings' => [], + 'instructors' => ['3'], + 'instructorGroups' => ['3'], + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'ilmSessions' => [ - [ - 'id' => 3, - 'learnerGroups' => ['2'], - 'instructorGroups' => [], - 'learners' => ['905'], - 'instructors' => [], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/ilmsessions', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=3', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 3, + 'instructors' => [], + 'instructorGroups' => [], + 'learners' => ['905'], + 'learnerGroups' => ['2'], + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'instructorGroups' => [ - [ - 'id' => 3, - 'title' => 'Drei', - 'school' => 1, - 'learnerGroups' => [], - 'ilmSessions' => [], - 'offerings' => ['1'], - 'users' => ['8', '9'], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/instructorgroups', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=3', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 3, + 'title' => 'Drei', + 'school' => 1, + 'learnerGroups' => [], + 'ilmSessions' => [], + 'offerings' => ['1'], + 'users' => ['8', '9'], + ], ], - ], - ])), - new Response(200, [], json_encode([ - 'instructorGroups' => [ - [ - 'id' => 1, - 'title' => 'Eins', - 'school' => 1, - 'learnerGroups' => [], - 'ilmSessions' => ['1'], - 'offerings' => ['1'], - 'users' => ['4', '5'], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/instructorgroups', $request->getUri()->getPath()); + $this->assertEquals('filters[id][]=1', urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 1, + 'title' => 'Eins', + 'school' => 1, + 'learnerGroups' => [], + 'ilmSessions' => ['1'], + 'offerings' => ['1'], + 'users' => ['4', '5'], + ], ], - ], - ])), + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -881,13 +975,19 @@ public function test_get(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode([ - 'schools' => [ - ['id' => 1, 'title' => 'Medicine'], - ['id' => 2, 'title' => 'Pharmacy'], - ], - ])), + function (RequestInterface $request) { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/schools', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine'], + ['id' => 2, 'title' => 'Pharmacy'], + ], + ])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); @@ -919,17 +1019,13 @@ public function test_get_with_filtering_and_sorting_criteria( set_config('apikey', $accesstoken, 'enrol_ilios'); set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); - $mockclient = $this->createMock(http_client::class); - $mockclient - ->expects($this->once()) - ->method('get') - ->with( - $this->equalTo('http://ilios.demo/api/v3/geflarkniks' . $expectedquerystring), - $this->anything(), - ) - ->willReturn(new Response(200, [], json_encode(['geflarkniks' => [['doesnt-really' => 'matter']]]))); - - di::set(http_client::class, $mockclient); + $handlerstack = HandlerStack::create(new MockHandler([ + function (RequestInterface $request) use ($expectedquerystring) { + $this->assertEquals($expectedquerystring, urldecode($request->getUri()->getQuery())); + return new Response(200, [], json_encode(['geflarkniks' => [['doesnt-really' => 'matter']]])); + }, + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); $ilios->get('geflarkniks', $filterby, $sortby); } @@ -943,12 +1039,12 @@ public function test_get_with_filtering_and_sorting_criteria( public static function get_with_filtering_and_sorting_provider(): array { return [ [[], [], ''], - [['foo' => 'bar'], [], '?filters[foo]=bar'], - [[], ['name' => 'DESC'], '?order_by[name]=DESC'], + [['foo' => 'bar'], [], 'filters[foo]=bar'], + [[], ['name' => 'DESC'], 'order_by[name]=DESC'], [ ['id' => [1, 2], 'school' => 5], ['title' => 'ASC'], - '?filters[id][]=1&filters[id][]=2&filters[school]=5&order_by[title]=ASC', + 'filters[id][]=1&filters[id][]=2&filters[school]=5&order_by[title]=ASC', ], ]; } @@ -961,18 +1057,23 @@ public function test_get_by_id(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode(['geflarknik' => [ - ['id' => 1, 'title' => 'whatever'], - ]])), + function(RequestInterface $request) { + $this->assertEquals('/api/v3/geflarkniks/12345', $request->getUri()->getPath()); + return new Response(200, [], json_encode(['geflarkniks' => [ + ['id' => 1, 'title' => 'whatever'], + ]])); + }, ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); - $response = $ilios->get_by_id('does-not-matter-here', 12345); - $this->assertObjectHasProperty('geflarknik', $response); - $this->assertCount(1, $response->geflarknik); - $this->assertEquals('1', $response->geflarknik[0]->id); - $this->assertEquals('whatever', $response->geflarknik[0]->title); + $response = $ilios->get_by_id('geflarkniks', 12345); + $this->assertObjectHasProperty('geflarkniks', $response); + $this->assertCount(1, $response->geflarkniks); + $this->assertEquals('1', $response->geflarkniks[0]->id); + $this->assertEquals('whatever', $response->geflarkniks[0]->title); } /** @@ -982,6 +1083,8 @@ public function test_get_by_id_fails_on_404(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ new Response(404, []), ])); @@ -1000,6 +1103,7 @@ public function test_get_by_id_returns_null_on_404(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ new Response(404, []), ])); @@ -1018,6 +1122,7 @@ public function test_get_by_id_returns_null_on_404(): void { public function test_get_fails_on_garbled_response(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ new Response(200, [], 'g00bleG0bble'), @@ -1039,6 +1144,7 @@ public function test_get_fails_on_empty_response(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ new Response(200, [], ''), ])); @@ -1223,4 +1329,3 @@ public static function invalid_token_provider(): array { ]; } } - From decfeafbef8e4afba7aeb11806a023606225c67e Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 2 Oct 2024 13:30:32 -0700 Subject: [PATCH 14/20] put logging output from sync under test coverage. --- tests/lib_test.php | 133 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 14 deletions(-) diff --git a/tests/lib_test.php b/tests/lib_test.php index 2e653a0..a113d20 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -33,7 +33,9 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use null_progress_trace; +use progress_trace_buffer; use Psr\Http\Message\RequestInterface; +use text_progress_trace; /** * Ilios enrolment tests. @@ -46,7 +48,6 @@ */ final class lib_test extends \advanced_testcase { - /** * Tests the enrolment of Ilios cohort members into a Moodle course. */ @@ -94,7 +95,7 @@ function (RequestInterface $request) { [ 'id' => 4, 'campusId' => 'xx1000004', - 'enabled' => false, // Disabled user account - should result in user unenrolment. + 'enabled' => false, // Disabled user account - should result in user enrolment suspension. ], [ 'id' => 5, @@ -138,10 +139,12 @@ function (RequestInterface $request) { $this->assertEquals(0, $DB->count_records('user_enrolments')); // Instantiate an enrolment instance that targets cohort members in Ilios. + $ilioscohortid = 1; // Ilios cohort ID. + $synctype = 'cohort'; // Enrol from cohort. $plugin->add_instance($course, [ - 'customint1' => 1, // Ilios cohort ID. - 'customint2' => 0, // Ilios learners enrolment. - 'customchar1' => 'cohort', // Enrol from cohort. + 'customint1' => $ilioscohortid, + 'customint2' => 0, + 'customchar1' => $synctype, 'roleid' => $studentrole->id, ] ); @@ -223,8 +226,44 @@ function (RequestInterface $request) { ); // Run enrolment sync. - $trace = new null_progress_trace(); + $trace = new progress_trace_buffer(new text_progress_trace(), false); $plugin->sync($trace, null); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString( + "Enrolling students to Course ID {$course->id} with Role ID " + . "{$studentrole->id} through Ilios Sync ID {$instance->id}.", + $output + ); + $this->assertStringContainsString('4 Ilios users found.', $output); + $this->assertStringContainsString( + 'enrolling with ' . ENROL_USER_ACTIVE . " status: userid {$user5->id} ==> courseid {$course->id}", + $output + ); + $this->assertEquals(1, substr_count($output, 'enrolling with ' . ENROL_USER_ACTIVE . ' status:')); + $this->assertStringContainsString( + "Suspending enrollment for disabled Ilios user: userid {$user4->id} ==> courseid {$course->id}.", + $output + ); + $this->assertEquals(1, substr_count($output, 'Suspending enrollment for disabled Ilios user:')); + $this->assertStringContainsString( + "Unenrolling users from Course ID {$course->id} with Role ID {$studentrole->id} " . + "that no longer associate with Ilios Sync ID {$instance->id}.", + $output + ); + $this->assertStringContainsString( + "unenrolling: {$user1->id} ==> {$course->id} via Ilios {$synctype} {$ilioscohortid}", + $output + ); + $this->assertEquals(1, substr_count($output, 'unenrolling:')); + $this->assertStringContainsString( + "unassigning role: {$user4->id} ==> {$course->id} as {$studentrole->shortname}", + $output + ); + $this->assertEquals(1, substr_count($output, 'unassigning role:')); // Check user enrolments post-sync. $this->assertEquals( @@ -399,11 +438,13 @@ function (RequestInterface $request) { ); $this->assertEquals(0, $DB->count_records('user_enrolments')); - // Instantiate an enrolment instance that targets cohort members in Ilios. + // Instantiate an enrolment instance that targets learner-group members in Ilios. + $ilioslearnergroupid = 1; // Ilios cohort ID. + $synctype = 'learnerGroup'; // Enrol from cohort. $plugin->add_instance($course, [ - 'customint1' => 1, // Ilios learner-group ID. - 'customint2' => 0, // Ilios learners enrolment. - 'customchar1' => 'learnerGroup', // Enrol from learner-group. + 'customint1' => $ilioslearnergroupid, + 'customint2' => 0, + 'customchar1' => $synctype, 'roleid' => $studentrole->id, ] ); @@ -485,8 +526,44 @@ function (RequestInterface $request) { ); // Run enrolment sync. - $trace = new null_progress_trace(); + $trace = new progress_trace_buffer(new text_progress_trace(), false); $plugin->sync($trace, null); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString( + "Enrolling students to Course ID {$course->id} with Role ID " + . "{$studentrole->id} through Ilios Sync ID {$instance->id}.", + $output + ); + $this->assertStringContainsString('4 Ilios users found.', $output); + $this->assertStringContainsString( + 'enrolling with ' . ENROL_USER_ACTIVE . " status: userid {$user5->id} ==> courseid {$course->id}", + $output + ); + $this->assertEquals(1, substr_count($output, 'enrolling with ' . ENROL_USER_ACTIVE . ' status:')); + $this->assertStringContainsString( + "Suspending enrollment for disabled Ilios user: userid {$user4->id} ==> courseid {$course->id}.", + $output + ); + $this->assertEquals(1, substr_count($output, 'Suspending enrollment for disabled Ilios user:')); + $this->assertStringContainsString( + "Unenrolling users from Course ID {$course->id} with Role ID {$studentrole->id} " . + "that no longer associate with Ilios Sync ID {$instance->id}.", + $output + ); + $this->assertStringContainsString( + "unenrolling: {$user1->id} ==> {$course->id} via Ilios {$synctype} {$ilioslearnergroupid}", + $output + ); + $this->assertEquals(1, substr_count($output, 'unenrolling:')); + $this->assertStringContainsString( + "unassigning role: {$user4->id} ==> {$course->id} as {$studentrole->shortname}", + $output + ); + $this->assertEquals(1, substr_count($output, 'unassigning role:')); // Check user enrolments post-sync. $this->assertEquals( @@ -809,10 +886,12 @@ function (RequestInterface $request) { $this->assertEquals(0, $DB->count_records('user_enrolments')); // Instantiate an enrolment instance that targets cohort members in Ilios. + $learnergroupid = 1; + $synctype = 'learnerGroup'; $plugin->add_instance($course, [ - 'customint1' => 1, // Ilios learner-group ID. + 'customint1' => $learnergroupid, 'customint2' => 1, // Ilios Instructors enrolment. - 'customchar1' => 'learnerGroup', // Enrol from learner-group. + 'customchar1' => $synctype, 'roleid' => $studentrole->id, ] ); @@ -841,8 +920,34 @@ function (RequestInterface $request) { $this->assertEquals(0, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); // Run enrolment sync. - $trace = new null_progress_trace(); + $trace = new progress_trace_buffer(new text_progress_trace(), false); $plugin->sync($trace, null); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString( + "Enrolling instructors to Course ID {$course->id} with Role ID " + . "{$studentrole->id} through Ilios Sync ID {$instance->id}.", + $output + ); + $this->assertStringContainsString('9 Ilios users found.', $output); + foreach ($users as $user) { + $this->assertStringContainsString( + 'enrolling with ' . ENROL_USER_ACTIVE . " status: userid {$user->id} ==> courseid {$course->id}", + $output + ); + } + $this->assertEquals(count($users), substr_count($output, 'enrolling with ' . ENROL_USER_ACTIVE . ' status:')); + $this->assertStringNotContainsString('Suspending enrollment for disabled Ilios user:', $output); + $this->assertStringContainsString( + "Unenrolling users from Course ID {$course->id} with Role ID {$studentrole->id} " . + "that no longer associate with Ilios Sync ID {$instance->id}.", + $output + ); + $this->assertStringNotContainsString('unenrolling:', $output); + $this->assertStringNotContainsString('unassigning role:', $output); // Check user enrolments post-sync. $this->assertEquals( From c8b24b49589a5d791cdea8a68cd8ca7d79732971 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 2 Oct 2024 15:29:55 -0700 Subject: [PATCH 15/20] add test scenario for changing enrolment status. --- tests/lib_test.php | 165 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 125 insertions(+), 40 deletions(-) diff --git a/tests/lib_test.php b/tests/lib_test.php index a113d20..cc9b169 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -69,7 +69,7 @@ function(RequestInterface $request) { 'cohorts' => [ [ 'id' => 1, - 'users' => ['2', '3', '4', '5'], + 'users' => ['2', '3', '4', '5', '6'], ], ], ])); @@ -77,7 +77,7 @@ function(RequestInterface $request) { function (RequestInterface $request) { $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); $this->assertEquals( - 'filters[id][]=2&filters[id][]=3&filters[id][]=4&filters[id][]=5', + 'filters[id][]=2&filters[id][]=3&filters[id][]=4&filters[id][]=5&filters[id][]=6', urldecode($request->getUri()->getQuery()) ); return new Response(200, [], json_encode([ @@ -102,6 +102,11 @@ function (RequestInterface $request) { 'campusId' => 'xx1000005', // Not currently enrolled - should result in new user enrolment. 'enabled' => true, ], + [ + 'id' => 6, + 'campusId' => 'xx1000006', // Currently with suspended enrolment in Moodle. + 'enabled' => true, + ], ], ])); }, @@ -123,6 +128,7 @@ function (RequestInterface $request) { $user3 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000003']); $user4 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000004']); $user5 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000005']); + $user6 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000006']); $this->assertEquals(0, $DB->count_records('enrol', ['enrol' => 'ilios'])); $this->assertEquals( @@ -158,15 +164,18 @@ function (RequestInterface $request) { // Enable the enrolment method. $CFG->enrol_plugins_enabled = 'ilios'; - // Enroll users 1-4, but not 5. + // Enroll users 1-4 and 6, but not 5. $plugin->enrol_user($instance, $user1->id, $studentrole->id); $plugin->enrol_user($instance, $user2->id, $studentrole->id); $plugin->enrol_user($instance, $user3->id, $studentrole->id); $plugin->enrol_user($instance, $user4->id, $studentrole->id); + $plugin->enrol_user($instance, $user6->id, $studentrole->id); + // Suspend enrolment of user 6. + $plugin->update_user_enrol($instance, $user6->id, ENROL_USER_SUSPENDED); // Check user enrolments pre-sync. $this->assertEquals( - 4, + 5, $DB->count_records( 'role_assignments', [ @@ -176,7 +185,7 @@ function (RequestInterface $request) { ] ) ); - $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertEquals(5, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); // Users 1 - 4 are actively enrolled in the course. foreach ([$user1, $user2, $user3, $user4] as $user) { @@ -192,15 +201,17 @@ function (RequestInterface $request) { strictness: MUST_EXIST ) ); - $userenrolment = $DB->get_record( + $this->assertNotEmpty( + $DB->get_record( 'user_enrolments', [ 'enrolid' => $instance->id, 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, ], strictness: MUST_EXIST + ) ); - $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); } // Verify that user 5 is not enrolled. @@ -225,6 +236,31 @@ function (RequestInterface $request) { ) ); + // Verify that user 6 has a suspended user enrolment. + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user6->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST, + ) + ); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user6->id, + 'status' => ENROL_USER_SUSPENDED, + ], + strictness: MUST_EXIST, + ) + ); + // Run enrolment sync. $trace = new progress_trace_buffer(new text_progress_trace(), false); $plugin->sync($trace, null); @@ -238,11 +274,16 @@ function (RequestInterface $request) { . "{$studentrole->id} through Ilios Sync ID {$instance->id}.", $output ); - $this->assertStringContainsString('4 Ilios users found.', $output); + $this->assertStringContainsString('5 Ilios users found.', $output); $this->assertStringContainsString( 'enrolling with ' . ENROL_USER_ACTIVE . " status: userid {$user5->id} ==> courseid {$course->id}", $output ); + $this->assertStringContainsString( + "changing enrollment status to '". ENROL_USER_ACTIVE . "' from '" . ENROL_USER_SUSPENDED . "': userid {$user6->id} ==> courseid {$course->id}", + $output + ); + $this->assertEquals(1, substr_count($output, 'changing enrollment status')); $this->assertEquals(1, substr_count($output, 'enrolling with ' . ENROL_USER_ACTIVE . ' status:')); $this->assertStringContainsString( "Suspending enrollment for disabled Ilios user: userid {$user4->id} ==> courseid {$course->id}.", @@ -267,7 +308,7 @@ function (RequestInterface $request) { // Check user enrolments post-sync. $this->assertEquals( - 3, + 4, $DB->count_records( 'role_assignments', [ @@ -277,10 +318,10 @@ function (RequestInterface $request) { ] ) ); - $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertEquals(5, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); - // User 2, 3, and now 5, are actively enrolled as students in the course. - foreach ([$user2, $user3, $user5] as $user) { + // User 2, 3, and now 5 and 6, are actively enrolled as students in the course. + foreach ([$user2, $user3, $user5, $user6] as $user) { $this->assertNotEmpty( $DB->get_record( 'role_assignments', @@ -298,10 +339,10 @@ function (RequestInterface $request) { [ 'enrolid' => $instance->id, 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, ], strictness: MUST_EXIST ); - $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); } // Verify that user1 has been fully unenrolled and has no role assigment in the course. $this->assertEmpty( @@ -368,7 +409,7 @@ function (RequestInterface $request) { 'learnerGroups' => [ [ 'id' => 1, - 'users' => ['2', '3', '4', '5'], + 'users' => ['2', '3', '4', '5', '6'], ], ], ])); @@ -376,7 +417,7 @@ function (RequestInterface $request) { function (RequestInterface $request) { $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); $this->assertEquals( - 'filters[id][]=2&filters[id][]=3&filters[id][]=4&filters[id][]=5', + 'filters[id][]=2&filters[id][]=3&filters[id][]=4&filters[id][]=5&filters[id][]=6', urldecode($request->getUri()->getQuery()) ); return new Response(200, [], json_encode([ @@ -401,6 +442,12 @@ function (RequestInterface $request) { 'campusId' => 'xx1000005', // Not currently enrolled - should result in new user enrolment. 'enabled' => true, ], + [ + 'id' => 6, + 'campusId' => 'xx1000006', // Currently with suspended enrolment in Moodle. + 'enabled' => true, + ], + ], ])); }, @@ -423,6 +470,7 @@ function (RequestInterface $request) { $user3 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000003']); $user4 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000004']); $user5 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000005']); + $user6 = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000006']); $this->assertEquals(0, $DB->count_records('enrol', ['enrol' => 'ilios'])); $this->assertEquals( @@ -458,15 +506,18 @@ function (RequestInterface $request) { // Enable the enrolment method. $CFG->enrol_plugins_enabled = 'ilios'; - // Enroll users 1-4, but not 5. + // Enroll users 1-4 and 6, but not 5. $plugin->enrol_user($instance, $user1->id, $studentrole->id); $plugin->enrol_user($instance, $user2->id, $studentrole->id); $plugin->enrol_user($instance, $user3->id, $studentrole->id); $plugin->enrol_user($instance, $user4->id, $studentrole->id); + $plugin->enrol_user($instance, $user6->id, $studentrole->id); + // Suspend enrolment of user 6. + $plugin->update_user_enrol($instance, $user6->id, ENROL_USER_SUSPENDED); // Check user enrolments pre-sync. $this->assertEquals( - 4, + 5, $DB->count_records( 'role_assignments', [ @@ -476,9 +527,9 @@ function (RequestInterface $request) { ] ) ); - $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertEquals(5, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); - // Users 1 - 4 are actively enrolled in the course. + // Users 1 - 4 actively enrolled in the course. foreach ([$user1, $user2, $user3, $user4] as $user) { $this->assertNotEmpty( $DB->get_record( @@ -492,15 +543,17 @@ function (RequestInterface $request) { strictness: MUST_EXIST ) ); - $userenrolment = $DB->get_record( - 'user_enrolments', - [ - 'enrolid' => $instance->id, - 'userid' => $user->id, - ], - strictness: MUST_EXIST + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, + ], + strictness: MUST_EXIST + ) ); - $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); } // Verify that user 5 is not enrolled. @@ -525,6 +578,31 @@ function (RequestInterface $request) { ) ); + // Verify that user 6 has a suspended user enrolment. + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user6->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST, + ) + ); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user6->id, + 'status' => ENROL_USER_SUSPENDED, + ], + strictness: MUST_EXIST, + ) + ); + // Run enrolment sync. $trace = new progress_trace_buffer(new text_progress_trace(), false); $plugin->sync($trace, null); @@ -538,12 +616,17 @@ function (RequestInterface $request) { . "{$studentrole->id} through Ilios Sync ID {$instance->id}.", $output ); - $this->assertStringContainsString('4 Ilios users found.', $output); + $this->assertStringContainsString('5 Ilios users found.', $output); $this->assertStringContainsString( 'enrolling with ' . ENROL_USER_ACTIVE . " status: userid {$user5->id} ==> courseid {$course->id}", $output ); $this->assertEquals(1, substr_count($output, 'enrolling with ' . ENROL_USER_ACTIVE . ' status:')); + $this->assertStringContainsString( + "changing enrollment status to '". ENROL_USER_ACTIVE . "' from '" . ENROL_USER_SUSPENDED . "': userid {$user6->id} ==> courseid {$course->id}", + $output + ); + $this->assertEquals(1, substr_count($output, 'changing enrollment status')); $this->assertStringContainsString( "Suspending enrollment for disabled Ilios user: userid {$user4->id} ==> courseid {$course->id}.", $output @@ -567,7 +650,7 @@ function (RequestInterface $request) { // Check user enrolments post-sync. $this->assertEquals( - 3, + 4, $DB->count_records( 'role_assignments', [ @@ -577,10 +660,10 @@ function (RequestInterface $request) { ] ) ); - $this->assertEquals(4, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertEquals(5, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); - // User 2, 3, and now 5, are actively enrolled as students in the course. - foreach ([$user2, $user3, $user5] as $user) { + // User 2, 3, and now 5 and 6, are actively enrolled as students in the course. + foreach ([$user2, $user3, $user5, $user6] as $user) { $this->assertNotEmpty( $DB->get_record( 'role_assignments', @@ -593,15 +676,17 @@ function (RequestInterface $request) { strictness: MUST_EXIST ) ); - $userenrolment = $DB->get_record( - 'user_enrolments', - [ - 'enrolid' => $instance->id, - 'userid' => $user->id, - ], - strictness: MUST_EXIST + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, + ], + strictness: MUST_EXIST + ) ); - $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); } // Verify that user1 has been fully unenrolled and has no role assigment in the course. $this->assertEmpty( From 399d0d2242b633c6dfedd5ef955995bd2644e61b Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 2 Oct 2024 15:32:01 -0700 Subject: [PATCH 16/20] test return value of sync method. --- tests/lib_test.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/lib_test.php b/tests/lib_test.php index cc9b169..5d8794f 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -263,7 +263,7 @@ function (RequestInterface $request) { // Run enrolment sync. $trace = new progress_trace_buffer(new text_progress_trace(), false); - $plugin->sync($trace, null); + $this->assertEquals(0, $plugin->sync($trace, null)); $output = $trace->get_buffer(); $trace->finished(); $trace->reset_buffer(); @@ -605,7 +605,7 @@ function (RequestInterface $request) { // Run enrolment sync. $trace = new progress_trace_buffer(new text_progress_trace(), false); - $plugin->sync($trace, null); + $this->assertEquals(0, $plugin->sync($trace, null)); $output = $trace->get_buffer(); $trace->finished(); $trace->reset_buffer(); @@ -1006,7 +1006,7 @@ function (RequestInterface $request) { // Run enrolment sync. $trace = new progress_trace_buffer(new text_progress_trace(), false); - $plugin->sync($trace, null); + $this->assertEquals(0, $plugin->sync($trace, null)); $output = $trace->get_buffer(); $trace->finished(); $trace->reset_buffer(); From f80439ea57df943d3255d716d3fe2096a00a2629 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 2 Oct 2024 15:33:48 -0700 Subject: [PATCH 17/20] renamed test methods to reflect the method-under-test. --- tests/lib_test.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/lib_test.php b/tests/lib_test.php index 5d8794f..1754c05 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -51,7 +51,7 @@ final class lib_test extends \advanced_testcase { /** * Tests the enrolment of Ilios cohort members into a Moodle course. */ - public function test_enrolment_from_ilios_cohort_members(): void { + public function test_sync_from_ilios_cohort_members(): void { global $CFG, $DB; $this->resetAfterTest(); @@ -392,7 +392,7 @@ function (RequestInterface $request) { /** * Tests the enrolment of Ilios learner-group members into a Moodle course. */ - public function test_enrolment_from_ilios_learner_group_members(): void { + public function test_sync_from_ilios_learner_group_members(): void { global $CFG, $DB; $this->resetAfterTest(); @@ -736,7 +736,7 @@ function (RequestInterface $request) { /** * Tests the enrolment of instructors to an Ilios learner-group (and its subgroups) into a Moodle course. */ - public function test_enrolment_from_ilios_learner_group_instructors(): void { + public function test_sync_from_ilios_learner_group_instructors(): void { global $CFG, $DB; $this->resetAfterTest(); From 8b2a94a5c65e466d460fc9b8836489500143a760 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 2 Oct 2024 16:15:52 -0700 Subject: [PATCH 18/20] add test coverage for disabled sync run. fixes unrelated linting errors while at it. --- tests/lib_test.php | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/lib_test.php b/tests/lib_test.php index 1754c05..5621d44 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -32,7 +32,6 @@ use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; -use null_progress_trace; use progress_trace_buffer; use Psr\Http\Message\RequestInterface; use text_progress_trace; @@ -280,7 +279,8 @@ function (RequestInterface $request) { $output ); $this->assertStringContainsString( - "changing enrollment status to '". ENROL_USER_ACTIVE . "' from '" . ENROL_USER_SUSPENDED . "': userid {$user6->id} ==> courseid {$course->id}", + "changing enrollment status to '". ENROL_USER_ACTIVE + . "' from '" . ENROL_USER_SUSPENDED . "': userid {$user6->id} ==> courseid {$course->id}", $output ); $this->assertEquals(1, substr_count($output, 'changing enrollment status')); @@ -623,7 +623,8 @@ function (RequestInterface $request) { ); $this->assertEquals(1, substr_count($output, 'enrolling with ' . ENROL_USER_ACTIVE . ' status:')); $this->assertStringContainsString( - "changing enrollment status to '". ENROL_USER_ACTIVE . "' from '" . ENROL_USER_SUSPENDED . "': userid {$user6->id} ==> courseid {$course->id}", + "changing enrollment status to '". ENROL_USER_ACTIVE + . "' from '" . ENROL_USER_SUSPENDED . "': userid {$user6->id} ==> courseid {$course->id}", $output ); $this->assertEquals(1, substr_count($output, 'changing enrollment status')); @@ -1073,4 +1074,41 @@ function (RequestInterface $request) { $this->assertEquals(ENROL_USER_ACTIVE, $userenrolment->status); } } + + /** + * Test that nothing happens when Ilios enrolment is not enabled. + */ + public function test_sync_disabled(): void { + global $DB; + $this->resetAfterTest(); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Create a course and set-up student-enrollment for it. Details beyond that don't really matter here. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + $course = $this->getDataGenerator()->create_course(); + $plugin->add_instance($course, [ + 'customint1' => 1, + 'customint2' => 0, + 'customchar1' => 'learnerGroup', + 'roleid' => $studentrole->id, + ] + ); + $this->assertEquals(1, $DB->count_records('enrol', ['enrol' => 'ilios'])); + $this->assertNotNull($DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*')); + + // Run the sync. + $trace = new progress_trace_buffer(new text_progress_trace(), false); + $this->assertEquals(2, $plugin->sync($trace, null)); // Note the non-zero exit code here. + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + $this->assertEquals( + 'Ilios enrolment sync plugin is disabled, unassigning all plugin roles and stopping.', + trim($output) + ); + } } From 3a2e6f15f1a6095b25005df5bb8b2d86f9f54859 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 2 Oct 2024 17:20:25 -0700 Subject: [PATCH 19/20] adds more user sync tests. do some cleanup on pre-existing tests while at it. --- tests/lib_test.php | 711 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 711 insertions(+) diff --git a/tests/lib_test.php b/tests/lib_test.php index 5621d44..f337edb 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -1111,4 +1111,715 @@ public function test_sync_disabled(): void { trim($output) ); } + + /** + * Test toggling of user enrolment. + */ + public function test_sync_unenrol_then_reenrol(): void { + global $CFG, $DB; + $this->resetAfterTest(); + + // Configure the Ilios API client. + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + set_config('unenrolaction', ENROL_EXT_REMOVED_UNENROL, 'enrol_ilios'); + + // Mock out the responses from the Ilios API. + $handlerstack = HandlerStack::create(new MockHandler([ + // API responses for first sync run. + function(RequestInterface $request) { + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'users' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [], // Don't include the user in the payload, this will trigger unenrollment downstream. + ])); + }, + // Second sync run responses. + function(RequestInterface $request) { + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'users' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [ + [ + 'id' => 1, + 'campusId' => 'xx1000001', + 'enabled' => true, // Add user to payload, this will result in re-enrollment. + ], + ], + ])); + }, + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Minimal setup here, one course with one actively enrolled user is enough. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + $user = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000001']); + + // Instantiate an enrolment instance that targets cohort members in Ilios. + $ilioscohortid = 1; // Ilios cohort ID. + $synctype = 'cohort'; // Enrol from cohort. + $plugin->add_instance($course, [ + 'customint1' => $ilioscohortid, + 'customint2' => 0, + 'customchar1' => $synctype, + 'roleid' => $studentrole->id, + ] + ); + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*', MUST_EXIST); + $CFG->enrol_plugins_enabled = 'ilios'; + $plugin->enrol_user($instance, $user->id, $studentrole->id); + + // Check user enrolments pre-sync. + $this->assertEquals( + 1, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST + ) + ); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, + ], + strictness: MUST_EXIST + ) + ); + + // Run enrolment sync. + $trace = new progress_trace_buffer(new text_progress_trace(), false); + $this->assertEquals(0, $plugin->sync($trace, null)); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString('0 Ilios users found.', $output); + $this->assertStringContainsString( + "unenrolling: {$user->id} ==> {$course->id} via Ilios {$synctype} {$ilioscohortid}", + $output + ); + + // Check user enrolment post-sync. + // Verify that the user has been fully unenrolled and has no role assigment in the course + // by checking that the course has currently no enrolments and role assignments. + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(0, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // Run the sync for a second time. + $trace = new progress_trace_buffer(new text_progress_trace(), false); + $this->assertEquals(0, $plugin->sync($trace, null)); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString('1 Ilios users found.', $output); + $this->assertStringContainsString( + 'enrolling with ' . ENROL_USER_ACTIVE . " status: userid {$user->id} ==> courseid {$course->id}", + $output + ); + + // Check user enrolment post-sync. + // Verify that the user has been fully re-enrolled + // and that the user has the student role assigment in the course. + $this->assertEquals( + 1, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ] + ) + ); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, + ] + ) + ); + } + + /** + * Test toggling of enrolment status. + */ + public function test_sync_suspend_then_unsuspend_enrolment(): void { + global $CFG, $DB; + $this->resetAfterTest(); + + // Configure the Ilios API client. + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + set_config('unenrolaction', ENROL_EXT_REMOVED_SUSPENDNOROLES, 'enrol_ilios'); + + // Mock out the responses from the Ilios API. + $handlerstack = HandlerStack::create(new MockHandler([ + // API responses for first sync run. + function(RequestInterface $request) { + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'users' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [], // Don't include the user in the payload, this will trigger unenrollment downstream. + ])); + }, + // Second sync run responses. + function(RequestInterface $request) { + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'users' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [ + [ + 'id' => 1, + 'campusId' => 'xx1000001', + 'enabled' => true, // Add user to payload, this will result in re-enrollment. + ], + ], + ])); + }, + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Minimal setup here, one course with one actively enrolled user is enough. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + $user = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000001']); + + // Instantiate an enrolment instance that targets cohort members in Ilios. + $ilioscohortid = 1; // Ilios cohort ID. + $synctype = 'cohort'; // Enrol from cohort. + $plugin->add_instance($course, [ + 'customint1' => $ilioscohortid, + 'customint2' => 0, + 'customchar1' => $synctype, + 'roleid' => $studentrole->id, + ] + ); + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*', MUST_EXIST); + $CFG->enrol_plugins_enabled = 'ilios'; + $plugin->enrol_user($instance, $user->id, $studentrole->id); + + // Check user enrolments pre-sync. + $this->assertEquals( + 1, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + + // Check user enrollment pre-sync. + $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ], + strictness: MUST_EXIST + ) + ); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, + ], + strictness: MUST_EXIST + ) + ); + + // Run enrolment sync. + $trace = new progress_trace_buffer(new text_progress_trace(), false); + $this->assertEquals(0, $plugin->sync($trace, null)); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString('0 Ilios users found.', $output); + $this->assertStringNotContainsString( + "unenrolling: {$user->id} ==> {$course->id} via Ilios {$synctype} {$ilioscohortid}", + $output + ); + $this->assertStringContainsString( + "suspending and unassigning all roles: userid {$user->id} ==> courseid {$course->id}", + $output + ); + + // Check user enrolment post-sync. + // Verify that the user's enrolment has been suspended + // and that the user's student role has been removed from the course. + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_SUSPENDED, + ] + ) + ); + + // Run the sync for a second time. + $trace = new progress_trace_buffer(new text_progress_trace(), false); + $this->assertEquals(0, $plugin->sync($trace, null)); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString('1 Ilios users found.', $output); + $this->assertStringContainsString( + "changing enrollment status to '". ENROL_USER_ACTIVE + . "' from '" . ENROL_USER_SUSPENDED . "': userid {$user->id} ==> courseid {$course->id}", + $output + ); + + // Check user enrolment post-sync. + // Verify that the user's enrolment has been reactivated and + // that the user's student role has been re-assigned in the course. + $this->assertEquals( + 1, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertNotEmpty( + $DB->get_record( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'userid' => $user->id, + 'contextid' => $context->id, + ] + ) + ); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_ACTIVE, + ] + ) + ); + } + + /** + * Test that suspended enrollments do not get re-activated for disabled Ilios users. + */ + public function test_sync_do_not_reactivate_suspended_enrolment_for_disabled_ilios_users(): void { + global $CFG, $DB; + $this->resetAfterTest(); + + // Configure the Ilios API client. + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + + // Mock out the responses from the Ilios API. + $handlerstack = HandlerStack::create(new MockHandler([ + function(RequestInterface $request) { + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'users' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [ + [ + 'id' => 1, + 'campusId' => 'xx1000001', + 'enabled' => false, // Disabled in Ilios. + ], + ], + ])); + }, + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Set up one course with one enrolled but suspended user is enough. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + $user = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000001']); + + // Instantiate an enrolment instance that targets cohort members in Ilios. + $ilioscohortid = 1; // Ilios cohort ID. + $synctype = 'cohort'; // Enrol from cohort. + $plugin->add_instance($course, [ + 'customint1' => $ilioscohortid, + 'customint2' => 0, + 'customchar1' => $synctype, + 'roleid' => $studentrole->id, + ] + ); + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*', MUST_EXIST); + $CFG->enrol_plugins_enabled = 'ilios'; + + // Enrol the user, then suspend the enrolment. + $plugin->enrol_user($instance, $user->id, $studentrole->id); + $plugin->update_user_enrol($instance, $user->id, ENROL_USER_SUSPENDED); + + // Check user enrolments pre-sync. + $this->assertEquals( + 1, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_SUSPENDED, + ], + strictness: MUST_EXIST + ) + ); + + // Run enrolment sync. + $trace = new progress_trace_buffer(new text_progress_trace(), false); + $this->assertEquals(0, $plugin->sync($trace, null)); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString('1 Ilios users found.', $output); + $this->assertStringNotContainsString( + "unenrolling: {$user->id} ==> {$course->id} via Ilios {$synctype} {$ilioscohortid}", + $output + ); + $this->assertStringNotContainsString( + "suspending and unassigning all roles: userid {$user->id} ==> courseid {$course->id}", + $output + ); + + // Check user enrolment post-sync. + // Verify that the user's enrolment has been suspended + // and that the user's student role has been removed from the course. + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + $this->assertNotEmpty( + $DB->get_record( + 'user_enrolments', + [ + 'enrolid' => $instance->id, + 'userid' => $user->id, + 'status' => ENROL_USER_SUSPENDED, + ], + strictness: MUST_EXIST + ) + ); + } + + /** + * Test that disabled Ilios users are not enrolled in the first place. + */ + public function test_sync_do_not_enrol_disabled_ilios_users(): void { + global $CFG, $DB; + $this->resetAfterTest(); + + // Configure the Ilios API client. + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + + // Mock out the responses from the Ilios API. + $handlerstack = HandlerStack::create(new MockHandler([ + function(RequestInterface $request) { + $this->assertEquals('ilios.demo', $request->getUri()->getHost()); + $this->assertEquals('/api/v3/cohorts/1', $request->getUri()->getPath()); + return new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'users' => ['1'], + ], + ], + ])); + }, + function (RequestInterface $request) { + $this->assertEquals('/api/v3/users', $request->getUri()->getPath()); + $this->assertEquals( + 'filters[id][]=1', + urldecode($request->getUri()->getQuery()) + ); + return new Response(200, [], json_encode([ + 'users' => [ + [ + 'id' => 1, + 'campusId' => 'xx1000001', + 'enabled' => false, // Disabled in Ilios. + ], + ], + ])); + }, + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + // Get a handle of the enrolment handler. + $plugin = enrol_get_plugin('ilios'); + + // Minimal setup here, one course without user enrolments. + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + $user = $this->getDataGenerator()->create_user(['idnumber' => 'xx1000001']); + + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + + // Instantiate an enrolment instance that targets cohort members in Ilios. + $ilioscohortid = 1; // Ilios cohort ID. + $synctype = 'cohort'; // Enrol from cohort. + $plugin->add_instance($course, [ + 'customint1' => $ilioscohortid, + 'customint2' => 0, + 'customchar1' => $synctype, + 'roleid' => $studentrole->id, + ] + ); + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'ilios'], '*', MUST_EXIST); + $CFG->enrol_plugins_enabled = 'ilios'; + + // Check user enrolments pre-sync. + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(0, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + + // Run enrolment sync. + $trace = new progress_trace_buffer(new text_progress_trace(), false); + $this->assertEquals(0, $plugin->sync($trace, null)); + $output = $trace->get_buffer(); + $trace->finished(); + $trace->reset_buffer(); + + // Check the logging output. + $this->assertStringContainsString('1 Ilios users found.', $output); + $this->assertStringNotContainsString( + "unenrolling: {$user->id} ==> {$course->id} via Ilios {$synctype} {$ilioscohortid}", + $output + ); + $this->assertStringNotContainsString( + 'enrolling with ' . ENROL_USER_ACTIVE . " status: userid {$user->id} ==> courseid {$course->id}", + $output + ); + $this->assertStringNotContainsString( + "suspending and unassigning all roles: userid {$user->id} ==> courseid {$course->id}", + $output + ); + + // Check user enrolments post-sync. + // Verify that not users have been enrolled and no user roles have been assigned in the course. + $this->assertEquals( + 0, + $DB->count_records( + 'role_assignments', + [ + 'roleid' => $studentrole->id, + 'component' => 'enrol_ilios', + + 'contextid' => $context->id, + ] + ) + ); + $this->assertEquals(0, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); + } } From c31e72df9338de7dd27ca3023faa3114407a5f30 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 2 Oct 2024 18:36:01 -0700 Subject: [PATCH 20/20] stub out future tests. these tests cover areas of the process that haven't changed during this refactor, but that should be covered with tests nonetheless. --- tests/lib_test.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/lib_test.php b/tests/lib_test.php index f337edb..fc71008 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -1822,4 +1822,32 @@ function (RequestInterface $request) { ); $this->assertEquals(0, $DB->count_records('user_enrolments', ['enrolid' => $instance->id])); } + + /** + * Test group enrolment during sync. + */ + public function test_sync_group_enrolment(): void { + $this->markTestIncomplete('to be done.'); + } + + /** + * Test that an explicitly set instance name is returned. + */ + public function test_get_instance_name_from_instance_name(): void { + $this->markTestIncomplete('to be done.'); + } + + /** + * Test that the correct plugin name is created from the sync-info as a fallback in case the instance has no name. + */ + public function test_get_instance_name_from_sync_info(): void { + $this->markTestIncomplete('to be done.'); + } + + /** + * Test that the correct plugin name is returned if not instance is given. + */ + public function test_get_instance_name_no_instance(): void { + $this->markTestIncomplete('to be done.'); + } }