AutorÃa | Ultima modificación | Ver Log |
<?php// This file is part of Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>.namespace mod_qbank\task;use context;use context_course;use context_coursecat;use context_module;use context_system;use core\task\manager;use core_question\local\bank\random_question_loader;use core_question\local\bank\question_bank_helper;use mod_quiz\quiz_settings;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 <simon.adams@catalyst-eu.net>* @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 \core\context\course Course context with used and unused questions. */private \core\context\course $usedunusedcontext;/** @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, qv.statusFROM {question} qJOIN {question_versions} qv ON qv.questionid = q.idJOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryidWHERE 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 with a quiz containing a random question from the system context.$randomcourse = self::getDataGenerator()->create_course(['shortname' => 'Random']);$randomquiz = $quizgenerator->create_instance(['course' => $randomcourse->id,'grade' => 100.0,'sumgrades' => 2,'layout' => '1,0',],);$randomquizsettings = quiz_settings::create($randomquiz->id);$structure = $randomquizsettings->get_structure();$topcategory = $DB->get_record('question_categories', ['contextid' => $sitecontext->id, 'parent' => 0]);$filtercondition = ['filter' => ['category' => ['jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT,'values' => [$topcategory->id],'filteroptions' => ['includesubcategories' => true],],],];$structure->add_random_questions(1, 1, $filtercondition);// 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('essay', 'files', ['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 an additional question with a missing type, to catch edge cases.$question4 = $questiongenerator->create_question('missingtype', 'invalid', ['category' => $coursecatcat->id]);$DB->set_field('question', 'qtype', 'invalid', ['id' => $question4->id]);// Create 2 nested categories with questions in them at course context level.$course = self::getDataGenerator()->create_course();$this->coursecontext = context_course::instance($course->id);$coursegrandparentcat = $this->create_question_category('Course Grandparent Cat', $this->coursecontext->id);$courseparentcat1 = $this->create_question_category('Course Parent Cat',$this->coursecontext->id,$coursegrandparentcat->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);// Include a stale question, which should not be migrated with the others.$question6 = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat1->id]);$DB->set_field('question_versions','status',question_version_status::QUESTION_STATUS_HIDDEN,['questionid' => $question6->id],);// 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]);}// Create additional versions of a stale question, all hidden.$staleversionquestion = reset($this->stalequestions);$questiongenerator->update_question($staleversionquestion, overrides: (array) $staleversionquestion);$questiongenerator->update_question($staleversionquestion, overrides: (array) $staleversionquestion);// 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);// Set up a course with three categories// - One contains questions including 1 that is used in a quiz.// - One contains questions that are not used anywhere, but are in "ready" state.// - One contains no questions.$course = self::getDataGenerator()->create_course(['shortname' => 'Used-Unused-Empty']);$this->usedunusedcontext = context_course::instance($course->id);$usedcategory = $this->create_question_category(name: 'Used Question Cat', contextid: $this->usedunusedcontext->id);$unusedcategory = $this->create_question_category('Unused Question Cat', $this->usedunusedcontext->id);$emptycategory = $this->create_question_category('Empty Cat', $this->usedunusedcontext->id);$question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $usedcategory->id]);$question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $usedcategory->id]);$question3 = $questiongenerator->create_question('shortanswer', null, ['category' => $unusedcategory->id]);$question4 = $questiongenerator->create_question('shortanswer', null, ['category' => $unusedcategory->id]);$quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']);quiz_add_quiz_question($question1->id, $quiz, 1);// The quiz also contains a random question from the used category.$quizsettings = quiz_settings::create($quiz->id);$structure = $quizsettings->get_structure();$filtercondition = ['filter' => ['category' => ['jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT,'values' => [$usedcategory->id],'filteroptions' => ['includesubcategories' => false],],],];$structure->add_random_questions(1, 1, $filtercondition);}/*** 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 the "Random" course has 1 quiz with 1 random question that returns the questions from the system top category.$randomcourse = $DB->get_record('course', ['shortname' => 'Random']);$coursemods = get_course_mods($randomcourse->id);$randomquiz = reset($coursemods);$randomquizsettings = quiz_settings::create($randomquiz->instance);$structure = $randomquizsettings->get_structure();$randomquestionslot = $structure->get_question_in_slot(1);$this->assertEquals($randomquestionslot->contextid, $sitecontext->id);$loader = new random_question_loader(new \qubaid_list([]));$randomquestions = $loader->get_filtered_questions($randomquestionslot->filtercondition['filter']);$this->assertCount(2, $randomquestions);$randomq1 = reset($randomquestions);$randomq2 = end($randomquestions);$this->assertEquals($parentcatq->id, $randomq1->id);$this->assertEquals($parentcat->id, $randomq1->category);$this->assertEquals($childcatq->id, $randomq2->id);$this->assertEquals($childcat->id, $randomq2->category);// 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 2 questions in the above course category level question category.$questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecatcats));$this->assertCount(2, $questions);$question = reset($questions);$this->assertEquals($parentcat->id, $question->categoryid);// Make sure there are files in the expected fileareas for this question.$fs = get_file_storage();$this->assertTrue($fs->file_exists($this->coursecatcontext->id, 'question', 'questiontext', $question->id, '/', '1.png'));$this->assertTrue($fs->file_exists($this->coursecatcontext->id, 'question', 'generalfeedback', $question->id, '/', '2.png'),);$this->assertTrue($fs->file_exists($this->coursecatcontext->id, 'qtype_essay', 'graderinfo', $question->id, '/', '3.png'));// Make sure we have 4 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(4, $allcoursecats);$grandparentcat = next($allcoursecats);$parentcat = next($allcoursecats);$this->assertEquals($grandparentcat->id, $parentcat->parent);$childcat = end($allcoursecats);$this->assertEquals($parentcat->id, $childcat->parent);$questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecats));// 2 active questions and 1 stale question, for a total of 3.$this->assertCount(3, $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);// There should be 4 question bank entries with 1 version each, and 1 with 3 versions, for a total of 7.$questionids = $this->get_question_data(array_map(static fn($cat) => $cat->id, $questioncats));$this->assertCount(7, $questionids);// Make sure the "Used-Unused-Empty" course has 4 question categories (including 'top') with 0, 2, 2, and 0// questions respectively.$questioncats = $DB->get_records('question_categories', ['contextid' => $this->usedunusedcontext->id], 'id ASC');$this->assertCount(4, $questioncats);$topcat = reset($questioncats);$this->assertEmpty($this->get_question_data([$topcat->id]));$usedcat = next($questioncats);$this->assertCount(2, $this->get_question_data([$usedcat->id]));$unusedcat = next($questioncats);$this->assertCount(2, $this->get_question_data([$unusedcat->id]));$emptycat = next($questioncats);$this->assertCount(0, $this->get_question_data([$emptycat->id]));// The question reference for the random question is using the "used" category, and the site context.$coursemods = get_course_mods($this->usedunusedcontext->instanceid);$quiz = reset($coursemods);$quizsettings = quiz_settings::create($quiz->instance);$structure = $quizsettings->get_structure();$randomquestionslot = $structure->get_question_in_slot(2);$this->assertEquals($this->usedunusedcontext->id, $randomquestionslot->contextid);$this->assertEquals($usedcat->id, $randomquestionslot->filtercondition['filter']['category']['values'][0]);}/*** 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 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);// The random question should now point to the questions in the site course question bank.$randomcourse = $DB->get_record('course', ['shortname' => 'Random']);$coursemods = get_course_mods($randomcourse->id);$randomquiz = reset($coursemods);$randomquizsettings = quiz_settings::create($randomquiz->instance);$structure = $randomquizsettings->get_structure();$randomquestionslot = $structure->get_question_in_slot(1);$this->assertEquals($randomquestionslot->contextid, $sitemodcontext->id);$loader = new random_question_loader(new \qubaid_list([]));$randomquestions = $loader->get_filtered_questions($randomquestionslot->filtercondition['filter']);$this->assertCount(2, $randomquestions);$randomq1 = reset($randomquestions);$randomq2 = end($randomquestions);$this->assertEquals($parentcat->id, $randomq1->category);$this->assertEquals($childcat->id, $randomq2->category);// 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');$grandparentcat = reset($courseqcats);$parentcat = next($courseqcats);$childcat = next($courseqcats);$this->assertEquals($topcat->id, $grandparentcat->parent);$this->assertEquals($grandparentcat->id, $parentcat->parent);$this->assertEquals($parentcat->id, $childcat->parent);// Make sure the two active questions were migrated with their categories, but not the stale question.$migratedquestions = $this->get_question_data([$parentcat->id, $childcat->id]);$this->assertCount(2, $migratedquestions);foreach ($migratedquestions as $migratedquestion) {$this->assertTrue($migratedquestion->status === question_version_status::QUESTION_STATUS_READY);}// 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]));}// Make sure the we did not create a qbank in the stale course.$this->assertEmpty(get_fast_modinfo($this->stalecoursecontext->instanceid)->get_instances_of('qbank'));// 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);// Used-Unused-Empty checks.// The empty category should have been removed. The other categories should both have been migrated to a qbank module,// with all of their questions.$usedunusedmodinfo = get_fast_modinfo($this->usedunusedcontext->instanceid);$usedunusedcourse = $usedunusedmodinfo->get_course();$usedunusedqbanks = $usedunusedmodinfo->get_instances_of('qbank');$usedunusedqbank = reset($usedunusedqbanks);$this->assertEquals("$usedunusedcourse->shortname shared question bank", $usedunusedqbank->name);// We should now only have 3 categories. Top, used and unused.$usedunusedcats = $DB->get_records('question_categories',['contextid' => $usedunusedqbank->context->id],fields: 'name, id',);$this->assertCount(3, $usedunusedcats);$this->assertArrayHasKey('top', $usedunusedcats);$this->assertArrayHasKey('Used Question Cat', $usedunusedcats);$this->assertArrayHasKey('Unused Question Cat', $usedunusedcats);$this->assertArrayNotHasKey('Empty Question Cat', $usedunusedcats);$this->assertEmpty($this->get_question_data([$usedunusedcats['top']->id]));$this->assertCount(2, $this->get_question_data([$usedunusedcats['Used Question Cat']->id]));$this->assertCount(2, $this->get_question_data([$usedunusedcats['Unused Question Cat']->id]));// The question reference for the random question is using the same category, but the new context.$modinfo = get_fast_modinfo($this->usedunusedcontext->instanceid);$quizzes = $modinfo->get_instances_of('quiz');$quiz = reset($quizzes);$quizsettings = quiz_settings::create($quiz->instance);$structure = $quizsettings->get_structure();$randomquestionslot = $structure->get_question_in_slot(2);$this->assertEquals($usedunusedqbank->context->id, $randomquestionslot->contextid);$this->assertEquals($usedunusedcats['Used Question Cat']->id,$randomquestionslot->filtercondition['filter']['category']['values'][0]);}/*** Assert the installation task handles the missing contexts correctly.** @return void*/public function test_qbank_install_with_missing_context(): void {global $DB;$this->resetAfterTest();self::setAdminUser();$questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question');// The problem is that question categories that used to related to contextids// which no longer exist are now all moved to the new system-level shared// question bank. This moving categories together can cause unique key violations.// Create 2 orphaned categories where the contextid no longer exists, with the same stamp and idnumber.// We need to do this by creating in a real context, then deleting the context,// because create category logs, which needs a valid context id.$tamperedstamp = make_unique_id_code();$context1 = context_course::instance(self::getDataGenerator()->create_course()->id);$oldcat1 = $this->create_question_category('Lost category 1', $context1->id);$oldcat1->stamp = $tamperedstamp;$oldcat1->idnumber = 'tamperedidnumber';$DB->update_record('question_categories', $oldcat1);$DB->delete_records('context', ['id' => $context1->id]);$context2 = context_course::instance(self::getDataGenerator()->create_course()->id);$oldcat2 = $this->create_question_category('Lost category 2', $context2->id);$oldcat2->stamp = $tamperedstamp;$oldcat2->idnumber = 'tamperedidnumber';$DB->update_record('question_categories', $oldcat2);$DB->delete_records('context', ['id' => $context2->id]);// Add a question to each category.$question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $oldcat1->id]);$question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $oldcat2->id]);// Make the questions 'in use'.$quizcourse = self::getDataGenerator()->create_course();$quiz = self::getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance(['course' => $quizcourse->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']);quiz_add_quiz_question($question1->id, $quiz);quiz_add_quiz_question($question2->id, $quiz);// Make sure the caches are reset so that the contexts are not cached.\core\context_helper::reset_caches();// Run the task.$task = new transfer_question_categories();$task->execute();// An important thing to verify is that the task completes without errors,// for example unique key violations.// Verify - there should be a single question bank in the site course with the expected name.$sitemodinfo = get_fast_modinfo(get_site());$siteqbanks = $sitemodinfo->get_instances_of('qbank');$this->assertCount(1, $siteqbanks);$siteqbank = reset($siteqbanks);$this->assertEquals('System shared question bank', $siteqbank->name);// The two previously orphaned categories should now be in this site questions bank, with a top category.$sitemodcontext = context_module::instance($siteqbank->get_course_module_record()->id);$sitemodcats = $DB->get_records_select('question_categories','parent <> 0 AND contextid = :contextid',['contextid' => $sitemodcontext->id],'id ASC',);// Work out which category is which.$movedcat1 = null;$movedcat2 = null;foreach ($sitemodcats as $movedcat) {if ($movedcat->name === $oldcat1->name) {$movedcat1 = $movedcat;}if ($movedcat->name === $oldcat2->name) {$movedcat2 = $movedcat;}}$this->assertNotNull($movedcat1);$this->assertNotNull($movedcat2);// Verify the properties of the moved categories.$this->assertNotEquals($movedcat1->stamp, $movedcat2->stamp);$this->assertNotEquals($movedcat1->idnumber, $movedcat2->idnumber);$this->assertEquals(question_get_top_category($sitemodcontext->id)->id, $movedcat1->parent);$this->assertEquals(question_get_top_category($sitemodcontext->id)->id, $movedcat2->parent);}public function test_fix_wrong_parents(): void {$this->resetAfterTest();$this->setup_pre_install_data();// Create a second course.$course2 = self::getDataGenerator()->create_course();$course2context = context_course::instance($course2->id);// In course2 we build this category structure:// - $course2parentcat -- context $course2context// - - $wrongchild1 -- context $this->coursecontext (wrong)// - - - $wronggrandchild1 -- context $this->coursecontext (same wrong)// - - - $doublywronggrandchild1 -- context $course2context (back right, but not matching its parent)// - - $wrongchild2 -- context non-existant A// - - - $wronggrandchild2 -- context non-existent A// - - - $doublywronggrandchild2 -- context non-existent B.$course2parentcat = $this->create_question_category('Course2 parent cat', $course2context->id);$wrongchild1 = $this->create_question_category('Child cat with wrong context', $this->coursecontext->id, $course2parentcat->id);$wronggrandchild1 = $this->create_question_category('Grandchild of child1 in same wrong context', $this->coursecontext->id, $wrongchild1->id);$doublywronggrandchild1 = $this->create_question_category('Grandchild of child1 back in the right context', $course2context->id, $wrongchild1->id);$wrongchild2 = $this->create_question_category('Child cat with non-existent context', $course2context->id + 1000, $course2parentcat->id);$wronggrandchild2 = $this->create_question_category('Grandchild of child2 with same non-existent context', $course2context->id + 1000, $wrongchild2->id);$doublywronggrandchild2 = $this->create_question_category('Grandchild of child2 with different non-existent context', $course2context->id + 2000, $wrongchild2->id);// Before we clean up, check that the expected categories are picked up.// $wronggrandchild1 & $wronggrandchild2 are not seen, because their contexts match// their parent's even though both are wrong. They should still get fixed.$task = new transfer_question_categories();$this->assertEquals([$wrongchild1->id => $wrongchild1->contextid,$doublywronggrandchild1->id => $course2context->id,$wrongchild2->id => $wrongchild2->contextid,$doublywronggrandchild2->id => $doublywronggrandchild2->contextid,],$task->get_categories_in_a_different_context_to_their_parent(),);// Call the cleanup method.$task->fix_wrong_parents();// Now we expect no mismatches.$this->assertEmpty($task->get_categories_in_a_different_context_to_their_parent());// Assert that the child categories have been moved to the locations they should have been.$this->assert_category_is_in_context_with_parent($this->coursecontext, null, $wrongchild1->id);$this->assert_category_is_in_context_with_parent($this->coursecontext, $wrongchild1, $wronggrandchild1->id);$this->assert_category_is_in_context_with_parent($course2context, null, $doublywronggrandchild1->id);$this->assert_category_is_in_context_with_parent($course2context, $course2parentcat, $wrongchild2->id);$this->assert_category_is_in_context_with_parent($course2context, $wrongchild2, $wronggrandchild2->id);$this->assert_category_is_in_context_with_parent($course2context, $wrongchild2, $doublywronggrandchild2->id);}/*** Assert that the category with id $categoryid is in context $expectedcontext, with the given parent.** @param context $expectedcontext the expected context for the category with id $categoryid.* @param stdClass|null $expectedparent the expected parent category.* null means the Top category in $expectedcontext.* @param int $categoryid the id of the category to check.*/protected function assert_category_is_in_context_with_parent(context $expectedcontext,?stdClass $expectedparent,int $categoryid,): void {global $DB;if ($expectedparent === null) {$expectedparent = $DB->get_record('question_categories',['contextid' => $expectedcontext->id, 'parent' => 0],'*',MUST_EXIST,);}$actualcategory = $DB->get_record('question_categories', ['id' => $categoryid]);$this->assertEquals($expectedparent->id, $actualcategory->parent,"Checking parent of category $actualcategory->name.");$this->assertEquals($expectedcontext->id, $actualcategory->contextid,"Checking context of category $actualcategory->name.");}public function test_transfer_questions(): void {global $DB;$this->resetAfterTest();$this->setup_pre_install_data();$task = new \mod_qbank\task\transfer_question_categories();$task->execute();// Assert that files are still in their original context.$courses = $DB->get_records('course', ['category' => $this->coursecatcontext->instanceid], 'id ASC');$newcourse = end($courses);$coursemodinfo = get_fast_modinfo($newcourse);$coursecatqbanks = $coursemodinfo->get_instances_of('qbank');$coursecatqbank = reset($coursecatqbanks);$coursecatqcats = $DB->get_records('question_categories', ['contextid' => $coursecatqbank->context->id], 'parent ASC');$parentcat = end($coursecatqcats);$questions = get_questions_category($parentcat, true);$question = reset($questions);$fs = get_file_storage();$this->assertTrue($fs->file_exists($this->coursecatcontext->id,'question','questiontext',$question->id,'/','1.png'));$this->assertTrue($fs->file_exists($this->coursecatcontext->id,'question','generalfeedback',$question->id,'/','2.png'));$this->assertTrue($fs->file_exists($this->coursecatcontext->id,'qtype_essay','graderinfo',$question->id,'/','3.png'));$this->assertFalse($fs->file_exists($coursecatqbank->context->id,'question','questiontext',$question->id,'/','1.png'));$this->assertFalse($fs->file_exists($coursecatqbank->context->id,'question','generalfeedback',$question->id,'/','2.png'));$this->assertFalse($fs->file_exists($coursecatqbank->context->id,'qtype_essay','graderinfo',$question->id,'/','3.png'));$this->assertFalse(question_bank_helper::has_bank_migration_task_completed_successfully());$questiontasks = manager::get_adhoc_tasks(transfer_questions::class);// We should have a transfer_questions task for each category that was moved.// 2 site categories,// 1 coursecat category,// 3 regular course categories,// 2 used/unused course categories.$this->assertCount(8, $questiontasks);$this->expectOutputRegex('~Moving files and tags~');// Delete one of the categories before running the tasks, to ensure missing categories are handled gracefully.$unusedcat = $DB->get_record('question_categories', ['name' => 'Unused Question Cat']);question_category_delete_safe($unusedcat);$this->expectOutputRegex("~Could not find a category record for id {$unusedcat->id}. Terminating task.~");$this->runAdhocTasks();// The files have now been moved to the new context.$this->assertFalse($fs->file_exists($this->coursecatcontext->id,'question','questiontext',$question->id,'/','1.png'));$this->assertFalse($fs->file_exists($this->coursecatcontext->id,'question','generalfeedback',$question->id,'/','2.png'));$this->assertFalse($fs->file_exists($this->coursecatcontext->id,'qtype_essay','graderinfo',$question->id,'/','3.png'));$this->assertTrue($fs->file_exists($coursecatqbank->context->id,'question','questiontext',$question->id,'/','1.png'));$this->assertTrue($fs->file_exists($coursecatqbank->context->id,'question','generalfeedback',$question->id,'/','2.png'));$this->assertTrue($fs->file_exists($coursecatqbank->context->id,'qtype_essay','graderinfo',$question->id,'/','3.png'));$this->assertTrue(question_bank_helper::has_bank_migration_task_completed_successfully());}}