From 1db7518a7dc885f20119e84f3f2aac67f63175ff Mon Sep 17 00:00:00 2001 From: Simon Adams Date: Fri, 2 Aug 2024 10:00:25 +0100 Subject: [PATCH] MDL-71378 mod_qbank: Add install script to transfer question categories --- lib/questionlib.php | 10 +- .../task/transfer_question_categories.php | 169 +++++++ mod/qbank/db/install.php | 28 ++ mod/qbank/db/install.xml | 1 + mod/qbank/tests/generator/lib.php | 13 +- .../transfer_question_categories_test.php | 438 ++++++++++++++++++ 6 files changed, 652 insertions(+), 7 deletions(-) create mode 100644 mod/qbank/classes/task/transfer_question_categories.php create mode 100644 mod/qbank/db/install.php create mode 100644 mod/qbank/tests/transfer_question_categories_test.php diff --git a/lib/questionlib.php b/lib/questionlib.php index 3474f411580..1b32cff3790 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -266,16 +266,14 @@ function question_category_delete_safe($category): void { } } if (!empty($questionids)) { - $parentcontextid = SYSCONTEXTID; $name = get_string('unknown', 'question'); if ($context !== false) { $name = $context->get_context_name(); - $parentcontext = $context->get_parent_context(); - if ($parentcontext) { - $parentcontextid = $parentcontext->id; - } + $parentcontext = $context->get_course_context(false); + $course = $parentcontext ? get_course($parentcontext->instanceid) : get_site(); } - question_save_from_deletion(array_keys($questionids), $parentcontextid, $name, $rescue); + $qbank = core_question\local\bank\question_bank_helper::get_default_open_instance_system_type($course, true); + question_save_from_deletion(array_keys($questionids), $qbank->context->id, $name, $rescue); } } diff --git a/mod/qbank/classes/task/transfer_question_categories.php b/mod/qbank/classes/task/transfer_question_categories.php new file mode 100644 index 00000000000..d3782678ad0 --- /dev/null +++ b/mod/qbank/classes/task/transfer_question_categories.php @@ -0,0 +1,169 @@ +. + +namespace mod_qbank\task; + +use context_system; +use core\context; +use core\task\adhoc_task; +use core\task\manager; +use core_course_category; +use core_question\local\bank\question_bank_helper; +use stdClass; + +/** + * /** + * This script transfers question categories at CONTEXT_SITE, CONTEXT_COURSE, & CONTEXT_COURSECAT to a new qbank instance + * context. + * + * Firstly, it finds any question categories where questions are not being used and deletes them, including questions. + * + * Then for any remaining, if it is at course level context, it creates a mod_qbank instance taking the course name + * and moves the category there including subcategories, files and tags. + * + * If the original question category context was at system context, then it creates a mod_qbank instance on the site course i.e. + * front page and moves the category & sub categories there, along with its files and tags. + * + * If the original question category context was a course category context, then it creates a course in that category, + * taking the category name. Then it creates a mod_qbank instance in that course and moves the category & sub categories + * there, along with files and tags belonging to those categories. + * + * @package mod_qbank + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Simon Adams + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class transfer_question_categories extends adhoc_task { + + /** + * Run the install task. + * + * @return void + */ + public function execute() { + + global $DB, $CFG; + + require_once($CFG->dirroot . '/course/modlib.php'); + require_once($CFG->libdir . '/questionlib.php'); + + $recordset = $DB->get_recordset('question_categories', ['parent' => 0]); + + foreach ($recordset as $oldtopcategory) { + + if (!$oldcontext = context::instance_by_id($oldtopcategory->contextid, IGNORE_MISSING)) { + // That context does not exist anymore, we will treat these as if they were at site context level. + $oldcontext = context_system::instance(); + } + + $trans = $DB->start_delegated_transaction(); + + // Remove any unused questions if they are marked as deleted. + // Also, if a category contained questions which were all unusable then delete it as well. + $subcategories = $DB->get_records_select('question_categories', + 'parent <> 0 AND contextid = :contextid', + ['contextid' => $oldtopcategory->contextid] + ); + // This gives us categories in parent -> child order so array_reverse it, + // because we should process stale categories from the bottom up. + $subcategories = array_reverse(\sort_categories_by_tree($subcategories, $oldtopcategory->id)); + foreach ($subcategories as $subcategory) { + \qbank_managecategories\helper::question_remove_stale_questions_from_category($subcategory->id); + if (!question_category_in_use($subcategory->id)) { + question_category_delete_safe($subcategory); + } + } + + // We don't want to transfer any categories at valid contexts i.e. quiz modules. + if ($oldcontext->contextlevel === CONTEXT_MODULE) { + $trans->allow_commit(); + continue; + } + + // Category is in use so let's process it. Firstly, a course and mod instance is needed. + switch ($oldcontext->contextlevel) { + case CONTEXT_SYSTEM: + $course = get_site(); + $bankname = get_string('systembank', 'question'); + break; + case CONTEXT_COURSECAT: + $coursecategory = core_course_category::get($oldcontext->instanceid); + $courseshortname = "{$coursecategory->name}-{$coursecategory->id}"; + $course = $this->create_course($coursecategory, $courseshortname); + $bankname = get_string("sharedbank", "mod_qbank", $coursecategory->name); + break; + case CONTEXT_COURSE: + $course = get_course($oldcontext->instanceid); + $bankname = get_string("sharedbank", "mod_qbank", $course->shortname); + break; + default: + // This shouldn't be possible, so we can't really transfer it. + // We should commit any pre-transfer category cleanup though. + $trans->allow_commit(); + continue 2; + } + + if (!$newmod = question_bank_helper::get_default_open_instance_system_type($course)) { + $newmod = question_bank_helper::create_default_open_instance($course, $bankname, question_bank_helper::TYPE_SYSTEM); + } + + // We have our new mod instance, now move all the subcategories of the old 'top' category to this new context. + $this->move_question_category($oldtopcategory, $newmod->context); + + // Job done, lets delete the old 'top' category. + $DB->delete_records('question_categories', ['id' => $oldtopcategory->id]); + $trans->allow_commit(); + } + + $recordset->close(); + } + + /** + * Wrapper for \create_course. + * + * @param core_course_category $coursecategory + * @param string $shortname + * @return stdClass + */ + protected function create_course(\core_course_category $coursecategory, string $shortname): stdClass { + $data = (object) [ + 'enablecompletion' => 0, + 'fullname' => get_string('coursecategory', 'mod_qbank', $coursecategory->name), + 'shortname' => $shortname, + 'category' => $coursecategory->id, + ]; + return \create_course($data); + } + + /** + * Create a new 'Top' category in our new context and move the old categories descendents beneath it. + * + * @param stdClass $oldtopcategory The old 'Top' category that we are moving. + * @param \context $newcontext The context we are moving our category to. + * @return void + */ + protected function move_question_category(stdClass $oldtopcategory, \context $newcontext): void { + global $DB; + + $newtopcategory = question_get_top_category($newcontext->id, true); + + // This function moves subcategories, so we have to start at the top. + question_move_category_to_context($oldtopcategory->id, $oldtopcategory->contextid, $newcontext->id); + + // Move the parent from the old top category to the new one. + $DB->set_field('question_categories', 'parent', $newtopcategory->id, ['parent' => $oldtopcategory->id]); + } +} diff --git a/mod/qbank/db/install.php b/mod/qbank/db/install.php new file mode 100644 index 00000000000..b970bee705a --- /dev/null +++ b/mod/qbank/db/install.php @@ -0,0 +1,28 @@ +. + +/** + * mod_qbank install operations to transfer question categories to new contexts. + * + * @package mod_qbank + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Simon Adams + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +function xmldb_qbank_install(): void { + $task = new \mod_qbank\task\transfer_question_categories(); + \core\task\manager::queue_adhoc_task($task); +} diff --git a/mod/qbank/db/install.xml b/mod/qbank/db/install.xml index 320ce8e33d6..fc6c0775863 100644 --- a/mod/qbank/db/install.xml +++ b/mod/qbank/db/install.xml @@ -13,6 +13,7 @@ + diff --git a/mod/qbank/tests/generator/lib.php b/mod/qbank/tests/generator/lib.php index fbffd23a646..5857eb34925 100644 --- a/mod/qbank/tests/generator/lib.php +++ b/mod/qbank/tests/generator/lib.php @@ -23,4 +23,15 @@ * @author Simon Adams * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class mod_qbank_generator extends testing_module_generator {} +class mod_qbank_generator extends testing_module_generator { + + #[\Override] + public function create_instance($record = null, ?array $options = null) { + $record = (object)(array)$record; + + if (empty($record->type)) { + $record->type = core_question\local\bank\question_bank_helper::TYPE_STANDARD; + } + return parent::create_instance($record, $options); + } +} diff --git a/mod/qbank/tests/transfer_question_categories_test.php b/mod/qbank/tests/transfer_question_categories_test.php new file mode 100644 index 00000000000..8d9f56ea85a --- /dev/null +++ b/mod/qbank/tests/transfer_question_categories_test.php @@ -0,0 +1,438 @@ +. + +namespace mod_qbank; + +use context_course; +use context_coursecat; +use context_module; +use context_system; +use stdClass; +use core_question\local\bank\question_version_status; + +/** + * Before testing, we firstly need to create some data to emulate what sites can have pre-upgrade. + * Namely, we are adding question categories and questions to deprecated contexts i.e. anything not CONTEXT_MODULE, + * and to quiz local banks too as we need to test these don't get touched. + * It also adds questions to some categories that are not used by quizzes anywhere. + * + * The tests cover a few areas. + * 1: We validate the data setup is correct before we run the installation script testing. + * 2: The installation test validates that any question categories not in CONTEXT_MODULE get transferred to relevant mod_qbank + * instances including their questions. It also validates that any stale questions that are not in use by quizzes are removed + * along with empty categories. + * + * @package mod_qbank + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Simon Adams + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_qbank\task\transfer_question_categories + */ +final class transfer_question_categories_test extends \advanced_testcase { + + /** @var \core\context\coursecat test course category context */ + private \core\context\coursecat $coursecatcontext; + + /** @var \core\context\course test course context */ + private \core\context\course $coursecontext; + + /** @var \core\context\course test stale course context*/ + private \core\context\course $stalecoursecontext; + + /** @var \core\context\module test quiz mod context */ + private \core\context\module $quizcontext; + + /** @var stdClass[] test stale questions */ + private array $stalequestions; + + /** + * Get question data from question category ids provided in the argument. + * + * @param array $categoryids + * @return array + */ + protected function get_question_data(array $categoryids): array { + global $DB; + + [$insql, $inparams] = $DB->get_in_or_equal($categoryids); + + $sql = "SELECT q.id, qbe.questioncategoryid AS categoryid + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid {$insql}"; + + return $DB->get_records_sql($sql, $inparams); + } + + /** + * This is hacky, but we can't use the API to create these as non module contexts are deprecated for holding question + * categories. + * + * @param string $name of the new category + * @param int $contextid of the module holding the category + * @param int $parentid of the new category + * @return stdClass category object + */ + protected function create_question_category(string $name, int $contextid, int $parentid = 0): stdClass { + + global $DB; + + if (!$parentid) { + if (!$parent = $DB->get_record('question_categories', ['contextid' => $contextid, 'parent' => 0, 'name' => 'top'])) { + $parent = new stdClass(); + $parent->name = 'top'; + $parent->info = ''; + $parent->contextid = $contextid; + $parent->parent = 0; + $parent->sortorder = 0; + $parent->stamp = make_unique_id_code(); + $parent->id = $DB->insert_record('question_categories', $parent); + } + $parentid = $parent->id; + } + + $record = (object) [ + 'name' => $name, + 'parent' => $parentid, + 'contextid' => $contextid, + 'info' => '', + 'infoformat' => FORMAT_HTML, + 'stamp' => make_unique_id_code(), + 'sortorder' => 999, + 'idnumber' => null, + ]; + + $record->id = $DB->insert_record('question_categories', $record); + return $record; + } + + /** + * Sets up the installation test with data. + * + * @return void + */ + protected function setup_pre_install_data(): void { + global $DB; + self::setAdminUser(); + $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question'); + $quizgenerator = self::getDataGenerator()->get_plugin_generator('mod_quiz'); + + // Setup 2 categories at site level context, with a question in each. + $sitecontext = context_system::instance(); + $site = get_site(); + + $siteparentcat = $this->create_question_category('Site Parent Cat', $sitecontext->id); + + $sitechildcat = $this->create_question_category('Site Child Cat', $sitecontext->id, $siteparentcat->id); + + $question1 = $questiongenerator->create_question( + 'shortanswer', + null, + ['category' => $siteparentcat->id, 'status' => question_version_status::QUESTION_STATUS_READY] + ); + $question2 = $questiongenerator->create_question( + 'shortanswer', + null, + ['category' => $sitechildcat->id, 'status' => question_version_status::QUESTION_STATUS_READY] + ); + + // Add a quiz to the site course and put those questions into it. + $quiz = $quizgenerator->create_instance(['course' => $site->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); + quiz_add_quiz_question($question1->id, $quiz, 1); + quiz_add_quiz_question($question2->id, $quiz, 1); + + // Create a course category and then a question category attached to that context. + $coursecategory = self::getDataGenerator()->create_category(); + $this->coursecatcontext = context_coursecat::instance($coursecategory->id); + $coursecatcat = $this->create_question_category('Course Cat Parent Cat', $this->coursecatcontext->id); + + // Add a question to the category just made. + $question3 = $questiongenerator->create_question('shortanswer', null, ['category' => $coursecatcat->id]); + + // Add a quiz to the course category and put those questions into it. + $course = self::getDataGenerator()->create_course(['category' => $coursecategory->id]); + $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); + quiz_add_quiz_question($question3->id, $quiz, 1); + + // Create 2 nested categories with questions in them at course context level. + $course = self::getDataGenerator()->create_course(); + $this->coursecontext = context_course::instance($course->id); + $courseparentcat1 = $this->create_question_category('Course Parent Cat', $this->coursecontext->id); + $coursechildcat1 = $this->create_question_category( + 'Course Child Cat', + $this->coursecontext->id, + $courseparentcat1->id + ); + + $question4 = $questiongenerator->create_question('shortanswer', null, ['category' => $courseparentcat1->id]); + $question5 = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat1->id]); + + // Make the questions 'in use'. + $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); + quiz_add_quiz_question($question4->id, $quiz, 1); + quiz_add_quiz_question($question5->id, $quiz, 1); + + // Create some nested categories with no questions in use. + $course = self::getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + $courseparentcat1 = $this->create_question_category('Stale Course Parent Cat1', $context->id); + $coursechildcat1 = $this->create_question_category('Stale Course Child Cat1', $context->id, $courseparentcat1->id); + $courseparentcat2 = $this->create_question_category('Stale Course Parent Cat2', $context->id); + $coursechildcat2 = $this->create_question_category('Stale Course Child Cat2', $context->id, $courseparentcat2->id); + $coursegrandchildcat1 = $this->create_question_category('Stale Course Grandchild Cat1', $context->id, $coursechildcat2->id); + $this->stalecoursecontext = context_course::instance($course->id); + + // Make all the questions hidden. + $this->stalequestions[] = $questiongenerator->create_question('shortanswer', + null, + ['category' => $courseparentcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] + ); + $this->stalequestions[] = $questiongenerator->create_question('shortanswer', + null, + ['category' => $coursechildcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] + ); + $this->stalequestions[] = $questiongenerator->create_question('shortanswer', + null, + ['category' => $courseparentcat2->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] + ); + $this->stalequestions[] = $questiongenerator->create_question('shortanswer', + null, + ['category' => $coursechildcat2->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] + ); + $this->stalequestions[] = $questiongenerator->create_question('shortanswer', + null, + ['category' => $coursegrandchildcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN] + ); + + foreach ($this->stalequestions as $question) { + $DB->set_field('question_versions', + 'status', + question_version_status::QUESTION_STATUS_HIDDEN, + ['questionid' => $question->id] + ); + } + + // Set up a quiz with some categories and questions attached to it. + $course = self::getDataGenerator()->create_course(); + $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); + $this->quizcontext = context_module::instance($quiz->cmid); + $quizparentcat1 = $this->create_question_category('Quiz Mod Parent Cat1', $this->quizcontext->id); + $quizchildcat1 = $this->create_question_category('Quiz Mod Child Cat1', $this->quizcontext->id, $quizparentcat1->id); + $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $quizparentcat1->id]); + $question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $quizchildcat1->id]); + quiz_add_quiz_question($question1->id, $quiz, 1); + quiz_add_quiz_question($question2->id, $quiz, 1); + } + + /** + * Asserts that the pre-installation setup is correct. + * + * @return void + */ + public function test_setup_pre_install_data(): void { + global $DB; + $this->resetAfterTest(); + $this->setup_pre_install_data(); + + $sitecontext = context_system::instance(); + $allsitecats = $DB->get_records('question_categories', ['contextid' => $sitecontext->id], 'id ASC'); + + // Make sure we have 2 site level question categories below 'top' and that the child is below the parent. + $this->assertCount(3, $allsitecats); + $parentcat = next($allsitecats); + $childcat = end($allsitecats); + $this->assertEquals($parentcat->id, $childcat->parent); + + // Make sure we have 1 question per the above site level question categories. + $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allsitecats)); + usort($questions, static fn($a, $b) => $a->categoryid <=> $b->categoryid); + $this->assertCount(2, $questions); + $parentcatq = reset($questions); + $childcatq = end($questions); + $this->assertEquals($parentcat->id, $parentcatq->categoryid); + $this->assertEquals($childcat->id, $childcatq->categoryid); + + // Make sure that the course category has a question category below 'top'. + $allcoursecatcats = $DB->get_records('question_categories', ['contextid' => $this->coursecatcontext->id], 'id ASC'); + $this->assertCount(2, $allcoursecatcats); + $topcat = reset($allcoursecatcats); + $parentcat = end($allcoursecatcats); + $this->assertEquals($topcat->id, $parentcat->parent); + + // Make sure we have 1 question in the above course category level question category. + $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecatcats)); + $this->assertCount(1, $questions); + $question = reset($questions); + $this->assertEquals($parentcat->id, $question->categoryid); + + // Make sure we have 3 question categories at course level (including 'top') with some questions in them. + $allcoursecats = $DB->get_records('question_categories', ['contextid' => $this->coursecontext->id], 'id ASC'); + $this->assertCount(3, $allcoursecats); + $parentcat = next($allcoursecats); + $childcat = end($allcoursecats); + $this->assertEquals($parentcat->id, $childcat->parent); + $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecats)); + $this->assertCount(2, $questions); + + // Make sure we have 6 stale question categories at course level (including 'top') with some questions in them. + $questioncats = $DB->get_records('question_categories', ['contextid' => $this->stalecoursecontext->id], 'id ASC'); + $this->assertCount(6, $questioncats); + $topcat = reset($questioncats); + $parentcat1 = next($questioncats); + $childcat1 = next($questioncats); + $parentcat2 = next($questioncats); + $childcat2 = next($questioncats); + $grandchildcat1 = next($questioncats); + $this->assertEquals($topcat->id, $parentcat1->parent); + $this->assertEquals($topcat->id, $parentcat2->parent); + $this->assertEquals($parentcat1->id, $childcat1->parent); + $this->assertEquals($parentcat2->id, $childcat2->parent); + $this->assertEquals($childcat2->id, $grandchildcat1->parent); + $questionids = $this->get_question_data(array_map(static fn($cat) => $cat->id, $questioncats)); + $this->assertCount(5, $questionids); + } + + /** + * Assert the installation task handles the deprecated contexts correctly. + * + * @return void + */ + public function test_qbank_install(): void { + global $DB; + $this->resetAfterTest(); + $this->setup_pre_install_data(); + + $task = new \mod_qbank\task\transfer_question_categories(); + $task->execute(); + + // Site context checks. + + $sitecontext = context_system::instance(); + $sitecontextcats = $DB->get_records('question_categories', ['contextid' => $sitecontext->id]); + + // Should be no site context question categories left, not even 'top'. + $this->assertCount(0, $sitecontextcats); + + $sitemodinfo = get_fast_modinfo(get_site()); + $siteqbanks = $sitemodinfo->get_instances_of('qbank'); + + // We should have 1 new module on the site course. + $this->assertCount(1, $siteqbanks); + $siteqbank = reset($siteqbanks); + + // Make doubly sure it got put into section 0 as these mod types are not rendered to the course page. + $this->assertEquals(0, $siteqbank->sectionnum); + + // It should have our determined name. + $this->assertEquals('System shared question bank', $siteqbank->name); + $sitemodcontext = context_module::instance($siteqbank->get_course_module_record()->id); + + // The 3 question categories including 'top' should now be at the new module context with their order intact. + $sitemodcats = $DB->get_records_select('question_categories', + 'parent <> 0 AND contextid = :contextid', + ['contextid' => $sitemodcontext->id], + 'id ASC' + ); + $this->assertCount(2, $sitemodcats); + $topcat = question_get_top_category($sitemodcontext->id); + $parentcat = reset($sitemodcats); + $childcat = next($sitemodcats); + $this->assertEquals($topcat->id, $parentcat->parent); + $this->assertEquals($parentcat->id, $childcat->parent); + + // Course category context checks. + + // Make sure that the course category has no question categories, not even 'top'. + $this->assertEquals(0, $DB->count_records('question_categories', ['contextid' => $this->coursecatcontext->id])); + + $courses = $DB->get_records('course', ['category' => $this->coursecatcontext->instanceid], 'id ASC'); + // We should have 2 courses in this category now, the original and the new one that holds our new mod instance. + $this->assertCount(2, $courses); + $newcourse = end($courses); + $coursecat = $DB->get_record('course_categories', ['id' => $newcourse->category]); + + // Make sure the new course shortname is a unique name based on the category name and id. + $this->assertEquals("{$coursecat->name}-{$coursecat->id}", $newcourse->shortname); + + // Make sure the new course fullname is based on the category name. + $this->assertEquals("Shared teaching resources for category: {$coursecat->name}", $newcourse->fullname); + + $coursemodinfo = get_fast_modinfo($newcourse); + $coursecatqbanks = $coursemodinfo->get_instances_of('qbank'); + + // We should have 1 new module on this course. + $this->assertCount(1, $coursecatqbanks); + $coursecatqbank = reset($coursecatqbanks); + + // Make sure the new module name is what we expect. + $this->assertEquals("{$coursecat->name} shared question bank", $coursecatqbank->name); + + $coursecatqcats = $DB->get_records('question_categories', ['contextid' => $coursecatqbank->context->id], 'parent ASC'); + + // The 2 question categories should be moved to the module context now. + $this->assertCount(2, $coursecatqcats); + $topcat = reset($coursecatqcats); + $parentcat = end($coursecatqcats); + + // Make sure the parent orders are correct. + $this->assertEquals($topcat->id, $parentcat->parent); + + // Course context checks. + + // Make sure that the course has no more question categories, not even 'top'. + $this->assertEquals(0, $DB->count_records('question_categories', ['contextid' => $this->coursecontext->id])); + + $coursemodinfo = get_fast_modinfo($this->coursecontext->instanceid); + $course = $coursemodinfo->get_course(); + $courseqbanks = $coursemodinfo->get_instances_of('qbank'); + + // We should have only 1 new mod instance in this course. + $this->assertCount(1, $coursecatqbanks); + + // The module name should be what we expect. + $courseqbank = reset($courseqbanks); + $this->assertEquals("{$course->shortname} shared question bank", $courseqbank->name); + + // Make sure the question categories still exist and that we have a new top one at the new module context. + $topcat = question_get_top_category($courseqbank->context->id); + $courseqcats = $DB->get_records_select('question_categories', + 'parent <> 0 AND contextid = :contextid', + ['contextid' => $courseqbank->context->id], + 'id ASC' + ); + $parentcat = reset($courseqcats); + $childcat = next($courseqcats); + $this->assertEquals($topcat->id, $parentcat->parent); + $this->assertEquals($parentcat->id, $childcat->parent); + + // Stale course context checks. + + // Make sure the stale course has no categories attached to it anymore and the questions were removed. + $this->assertFalse($DB->record_exists('question_categories', ['contextid' => $this->stalecoursecontext->id])); + foreach ($this->stalequestions as $stalequestion) { + $this->assertFalse($DB->record_exists('question', ['id' => $stalequestion->id])); + } + + // Quiz module checks. + + // Make sure the 3 categories at quiz context, including 'top' have not been touched. + $quizcategories = $DB->get_records('question_categories', ['contextid' => $this->quizcontext->id]); + $this->assertCount(3, $quizcategories); + $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $quizcategories)); + $this->assertCount(2, $questions); + } +}